# Solstice Countdown Widget Implementation Plan > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. **Goal:** Build an iOS app with a widget showing countdown to the next solstice/equinox with season-specific imagery, sunrise/sunset times, and upcoming event details. **Architecture:** - Core models (`SolsticeEvent`, `SolsticeData`) manage solstice dates and calculations - `SunTimes` calculates sunrise/sunset using NOAA algorithm based on cached user location - `AppGroupManager` handles syncing data between main app and widget via shared container - Widget supports three sizes (small, medium, large) with responsive layouts - Main app info screen displays expanded details (upcoming events, sun times, progress) **Tech Stack:** SwiftUI, WidgetKit, Combine, UserDefaults (AppGroup), iOS 16+ --- ## File Structure ### New Files to Create **Models:** - `Solverv/Models/SolsticeEvent.swift` — Data model for a single solstice/equinox event - `Solverv/Models/SolsticeData.swift` — Manager for solstice dates (2025-2030) and calculations - `Solverv/Models/Season.swift` — Enum for seasons with color/description **Utilities:** - `Solverv/Utilities/SunTimes.swift` — Sunrise/sunset calculation (NOAA algorithm) - `Solverv/Utilities/AppGroupManager.swift` — Read/write AppGroup container **Views (App):** - `Solverv/Views/InfoScreenView.swift` — Main app info screen (upcoming events, sun times, progress) **Views (Widget):** - `Solsnu.Widget/Views/SmallWidgetView.swift` — 169×169 widget layout - `Solsnu.Widget/Views/MediumWidgetView.swift` — 364×169 widget layout - `Solsnu.Widget/Views/LargeWidgetView.swift` — 364×364 widget layout **Tests:** - `SolverVTests/Models/SolsticeDataTests.swift` — Test solstice calculations - `SolverVTests/Utilities/SunTimesTests.swift` — Test sunrise/sunset calculations - `SolverVTests/Utilities/AppGroupManagerTests.swift` — Test data persistence ### Files to Modify - `Solverv/SolvervApp.swift` — Add location permission request on launch - `Solverv/ContentView.swift` — Add navigation to info screen - `Solsnu.Widget/Solsnu_Widget.swift` — Update provider and widget to support all sizes - `Solsnu.Widget/Solsnu_WidgetBundle.swift` — Register all widget kinds - `Solverv/Assets.xcassets` — Add season images (light & dark variants) --- ## Implementation Tasks ### Task 1: Create Season Enum **Files:** - Create: `Solverv/Models/Season.swift` - Test: `SolverVTests/Models/SeasonTests.swift` - [ ] **Step 1: Write Season enum** ```swift // Solverv/Models/Season.swift 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 } } } ``` - [ ] **Step 2: Verify Season enum compiles** Run: `xcodebuild -scheme Solverv -destination 'generic/platform=iOS' build` Expected: Build succeeds, no errors - [ ] **Step 3: Commit** ```bash git add Solverv/Models/Season.swift git commit -m "feat: add Season enum with colors and descriptions" ``` --- ### Task 2: Create SolsticeEvent Model **Files:** - Create: `Solverv/Models/SolsticeEvent.swift` - [ ] **Step 1: Write SolsticeEvent struct** ```swift // Solverv/Models/SolsticeEvent.swift 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) var 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) } } ``` - [ ] **Step 2: Verify SolsticeEvent compiles** Run: `xcodebuild -scheme Solverv -destination 'generic/platform=iOS' build` Expected: Build succeeds - [ ] **Step 3: Commit** ```bash git add Solverv/Models/SolsticeEvent.swift git commit -m "feat: add SolsticeEvent model with UTC-to-local conversion" ``` --- ### Task 3: Create SolsticeData Manager **Files:** - Create: `Solverv/Models/SolsticeData.swift` - Test: `SolverVTests/Models/SolsticeDataTests.swift` - [ ] **Step 1: Write SolsticeData with hardcoded events** ```swift // Solverv/Models/SolsticeData.swift import Foundation class SolsticeData { static let shared = SolsticeData() private let events: [SolsticeEvent] init() { // Hardcoded solstice/equinox dates (UTC) self.events = [ // 2025 SolsticeEvent(name: "Spring Equinox", date: isoDate("2025-03-20 09:01:00"), season: .spring), SolsticeEvent(name: "Summer Solstice", date: isoDate("2025-06-20 14:42:00"), season: .summer), SolsticeEvent(name: "Autumn Equinox", date: isoDate("2025-09-22 18:20:00"), season: .autumn), SolsticeEvent(name: "Winter Solstice", date: isoDate("2025-12-21 15:03:00"), season: .winter), // 2026 SolsticeEvent(name: "Spring Equinox", date: isoDate("2026-03-20 14:46:00"), season: .spring), SolsticeEvent(name: "Summer Solstice", date: isoDate("2026-06-21 08:25:00"), season: .summer), SolsticeEvent(name: "Autumn Equinox", date: isoDate("2026-09-23 00:06:00"), season: .autumn), SolsticeEvent(name: "Winter Solstice", date: isoDate("2026-12-21 20:50:00"), season: .winter), // 2027 SolsticeEvent(name: "Spring Equinox", date: isoDate("2027-03-20 20:25:00"), season: .spring), SolsticeEvent(name: "Summer Solstice", date: isoDate("2027-06-21 14:11:00"), season: .summer), SolsticeEvent(name: "Autumn Equinox", date: isoDate("2027-09-23 06:02:00"), season: .autumn), SolsticeEvent(name: "Winter Solstice", date: isoDate("2027-12-22 02:43:00"), season: .winter), // 2028 SolsticeEvent(name: "Spring Equinox", date: isoDate("2028-03-20 02:17:00"), season: .spring), SolsticeEvent(name: "Summer Solstice", date: isoDate("2028-06-20 20:02:00"), season: .summer), SolsticeEvent(name: "Autumn Equinox", date: isoDate("2028-09-22 11:45:00"), season: .autumn), SolsticeEvent(name: "Winter Solstice", date: isoDate("2028-12-21 08:20:00"), season: .winter), // 2029 SolsticeEvent(name: "Spring Equinox", date: isoDate("2029-03-20 08:01:00"), season: .spring), SolsticeEvent(name: "Summer Solstice", date: isoDate("2029-06-21 01:48:00"), season: .summer), SolsticeEvent(name: "Autumn Equinox", date: isoDate("2029-09-22 17:37:00"), season: .autumn), SolsticeEvent(name: "Winter Solstice", date: isoDate("2029-12-21 14:14:00"), season: .winter), // 2030 SolsticeEvent(name: "Spring Equinox", date: isoDate("2030-03-20 13:51:00"), season: .spring), SolsticeEvent(name: "Summer Solstice", date: isoDate("2030-06-21 07:31:00"), season: .summer), SolsticeEvent(name: "Autumn Equinox", date: isoDate("2030-09-22 23:27:00"), season: .autumn), SolsticeEvent(name: "Winter Solstice", date: isoDate("2030-12-21 20:09:00"), season: .winter), ] } /// Return next upcoming solstice/equinox func nextEvent() -> SolsticeEvent? { let now = Date() return events.first { $0.date > now } } /// Return next N upcoming events func upcomingEvents(count: Int) -> [SolsticeEvent] { let now = Date() return events.filter { $0.date > now }.prefix(count).map { $0 } } /// Progress from last event to next event as (elapsed, total) days func progressToNextEvent() -> (elapsed: Int, total: Int)? { let now = Date() guard let next = nextEvent() else { return nil } // Find the previous event let prev = events.last { $0.date <= now } guard let previous = prev else { return nil } let elapsed = Calendar.current.dateComponents([.day], from: previous.date, to: now).day ?? 0 let total = Calendar.current.dateComponents([.day], from: previous.date, to: next.date).day ?? 0 return (elapsed, total) } private func isoDate(_ string: String) -> Date { let formatter = DateFormatter() formatter.dateFormat = "yyyy-MM-dd HH:mm:ss" formatter.timeZone = TimeZone(abbreviation: "UTC") return formatter.date(from: string) ?? Date() } } ``` - [ ] **Step 2: Write test for nextEvent()** ```swift // SolverVTests/Models/SolsticeDataTests.swift import XCTest @testable import Solverv final class SolsticeDataTests: XCTestCase { var data: SolsticeData! override func setUp() { super.setUp() data = SolsticeData() } func testNextEventReturnsValidEvent() { let next = data.nextEvent() XCTAssertNotNil(next) XCTAssertGreaterThan(next!.date, Date()) } func testUpcomingEventsReturnsCorrectCount() { let upcoming = data.upcomingEvents(count: 3) XCTAssertEqual(upcoming.count, 3) for event in upcoming { XCTAssertGreaterThan(event.date, Date()) } } func testProgressToNextEventReturnsValidRatio() { guard let progress = data.progressToNextEvent() else { XCTFail("Progress should not be nil") return } XCTAssertGreaterThanOrEqual(progress.elapsed, 0) XCTAssertGreaterThan(progress.total, 0) XCTAssertLessThanOrEqual(progress.elapsed, progress.total) } } ``` - [ ] **Step 3: Run tests** Run: `xcodebuild -scheme SolverVTests -destination 'generic/platform=iOS' test` Expected: All tests pass - [ ] **Step 4: Commit** ```bash git add Solverv/Models/SolsticeData.swift SolverVTests/Models/SolsticeDataTests.swift git commit -m "feat: add SolsticeData manager with hardcoded events 2025-2030" ``` --- ### Task 4: Create SunTimes Sunrise/Sunset Calculator **Files:** - Create: `Solverv/Utilities/SunTimes.swift` - Test: `SolverVTests/Utilities/SunTimesTests.swift` - [ ] **Step 1: Write test cases first (TDD)** ```swift // SolverVTests/Utilities/SunTimesTests.swift import XCTest @testable import Solverv final class SunTimesTests: XCTestCase { // Known reference values from NOAA calculator // Location: Oslo (59.9139°N, 10.7522°E) // Date: March 20, 2026 (Spring Equinox) // Expected: Sunrise ~06:42, Sunset ~18:13 (within ±2 minutes) func testSpringEquinoxOslo() { let dateComponents = DateComponents(year: 2026, month: 3, day: 20, hour: 12) let date = Calendar.current.date(from: dateComponents)! let sunTimes = SunTimes(latitude: 59.9139, longitude: 10.7522, date: date) guard let sunrise = sunTimes.sunrise(), let sunset = sunTimes.sunset() else { XCTFail("Sunrise/sunset should not be nil") return } let sr = Calendar.current.dateComponents([.hour, .minute], from: sunrise) let ss = Calendar.current.dateComponents([.hour, .minute], from: sunset) // Sunrise should be ~6:42 XCTAssertEqual(sr.hour, 6) XCTAssertGreaterThanOrEqual(sr.minute ?? 0, 40) XCTAssertLessThanOrEqual(sr.minute ?? 0, 44) // Sunset should be ~18:13 XCTAssertEqual(ss.hour, 18) XCTAssertGreaterThanOrEqual(ss.minute ?? 0, 11) XCTAssertLessThanOrEqual(ss.minute ?? 0, 15) } func testSunriseBeforeSunset() { let dateComponents = DateComponents(year: 2026, month: 6, day: 21) let date = Calendar.current.date(from: dateComponents)! let sunTimes = SunTimes(latitude: 59.9139, longitude: 10.7522, date: date) guard let sunrise = sunTimes.sunrise(), let sunset = sunTimes.sunset() else { XCTFail("Sunrise/sunset should not be nil") return } XCTAssertLessThan(sunrise, sunset) } func testPolarNight() { // Tromsø, Norway (69.6°N) in December let dateComponents = DateComponents(year: 2026, month: 12, day: 21) let date = Calendar.current.date(from: dateComponents)! let sunTimes = SunTimes(latitude: 69.6, longitude: 18.95, date: date) // Should return nil or special handling for polar night _ = sunTimes.sunrise() _ = sunTimes.sunset() // Just verify no crash } } ``` - [ ] **Step 2: Run tests to verify they fail** Run: `xcodebuild -scheme SolverVTests -destination 'generic/platform=iOS' test -only SunTimesTests` Expected: FAIL (SunTimes class not yet created) - [ ] **Step 3: Write SunTimes class (NOAA algorithm - Part A: Core calculation)** ```swift // Solverv/Utilities/SunTimes.swift 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 } /// Calculate sunrise time for the location and date func sunrise() -> Date? { guard let result = calculateSunTimes() else { return nil } return result.sunrise } /// Calculate sunset time for the location and date func sunset() -> Date? { guard let result = calculateSunTimes() else { return nil } return result.sunset } // NOAA solar position algorithm (simplified) // 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, let month = components.month, let day = components.day else { return nil } // Step 1: Calculate day of year let dayOfYear = calendar.dateComponents([.day], from: calendar.date(from: DateComponents(year: year, month: 1, day: 1))!, to: date).day! + 1 // Step 2: Fractional year in radians let daysInYear = isLeapYear(year) ? 366 : 365 let gamma = 2.0 * Double.pi * Double(dayOfYear - 1) / Double(daysInYear) // Step 3: Solar declination (radians) 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) // Step 4: Equation of time (minutes) 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)) // Step 5: Hour angle at sunrise/sunset let latRad = latitude * Double.pi / 180.0 let cosH = -tan(latRad) * tan(decl) guard cosH >= -1.0 && cosH <= 1.0 else { // Sun is always up or always down at this latitude/date return nil } let h = acos(cosH) * 180.0 / Double.pi // Step 6: Solar noon in local solar time let solarNoon = 12.0 - (longitude / 15.0) - (eot / 60.0) // Step 7: Sunrise and sunset in local solar time let sunriseLST = solarNoon - (h / 15.0) let sunsetLST = solarNoon + (h / 15.0) // Step 8: Convert to standard time (account for timezone offset only, not longitude) let tzOffset = Double(TimeZone.current.secondsFromGMT(for: date)) / 3600.0 let sunriseHour = sunriseLST + tzOffset let sunsetHour = sunsetLST + tzOffset // Step 9: Create date components, handling day boundary crossing let calendar2 = Calendar.current var baseComponents = calendar2.dateComponents([.year, .month, .day], from: date) // Sunrise var sunriseComp = baseComponents var srHour = Int(sunriseHour) var srMinute = Int((sunriseHour - Double(Int(sunriseHour))) * 60) // Handle previous day crossing if srHour < 0 { if let prevDay = calendar2.date(byAdding: .day, value: -1, to: date) { let prevComponents = calendar2.dateComponents([.year, .month, .day], from: prevDay) sunriseComp = prevComponents srHour = 24 + srHour } } sunriseComp.hour = max(0, min(23, srHour)) sunriseComp.minute = max(0, min(59, srMinute)) sunriseComp.second = 0 // Sunset var sunsetComp = baseComponents var ssHour = Int(sunsetHour) var ssMinute = Int((sunsetHour - Double(Int(sunsetHour))) * 60) // Handle next day crossing if ssHour >= 24 { if let nextDay = calendar2.date(byAdding: .day, value: 1, to: date) { let nextComponents = calendar2.dateComponents([.year, .month, .day], from: nextDay) sunsetComp = nextComponents ssHour = ssHour - 24 } } sunsetComp.hour = max(0, min(23, ssHour)) sunsetComp.minute = max(0, min(59, ssMinute)) sunsetComp.second = 0 guard let sunriseDate = calendar2.date(from: sunriseComp), let sunsetDate = calendar2.date(from: sunsetComp) else { return nil } return (sunriseDate, sunsetDate) } private func isLeapYear(_ year: Int) -> Bool { return (year % 4 == 0 && year % 100 != 0) || (year % 400 == 0) } } ``` - [ ] **Step 4: Implement SunTimes (correct algorithm with day boundary handling)** Full implementation already shown above with corrected math. - [ ] **Step 5: Run tests to verify they pass** Run: `xcodebuild -scheme SolverVTests -destination 'generic/platform=iOS' test -only SunTimesTests` Expected: PASS (3 test cases pass) - [ ] **Step 6: Commit** ```bash git add Solverv/Utilities/SunTimes.swift SolverVTests/Utilities/SunTimesTests.swift git commit -m "feat: add SunTimes calculator using NOAA algorithm with test-driven approach" ``` --- ### Task 5: Create AppGroupManager **Files:** - Create: `Solverv/Utilities/AppGroupManager.swift` - Test: `SolverVTests/Utilities/AppGroupManagerTests.swift` - [ ] **Step 1: Write AppGroupManager** ```swift // Solverv/Utilities/AppGroupManager.swift 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 // ISO 8601 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 // ISO 8601 date only (YYYY-MM-DD) let sunrise: String // ISO 8601 datetime let sunset: String // ISO 8601 datetime let timestamp: String // ISO 8601 when calculated } 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") } } ``` - [ ] **Step 2: Write test for AppGroupManager** ```swift // SolverVTests/Utilities/AppGroupManagerTests.swift import XCTest @testable import Solverv final class AppGroupManagerTests: XCTestCase { var manager: AppGroupManager! override func setUp() { super.setUp() manager = AppGroupManager() manager.clearAllData() } override func tearDown() { super.tearDown() manager.clearAllData() } func testSaveAndRetrieveLocation() { let location = AppGroupManager.UserLocation( latitude: 59.9139, longitude: 10.7522, timestamp: "2026-03-23T10:00:00Z", isDefaultLocation: false ) manager.saveLocation(location) let retrieved = manager.getLocation() XCTAssertNotNil(retrieved) XCTAssertEqual(retrieved?.latitude, 59.9139) XCTAssertEqual(retrieved?.longitude, 10.7522) } func testSaveAndRetrieveSunTimes() { let sunTimes = AppGroupManager.SunTimes( date: "2026-03-20", sunrise: "2026-03-20T06:42:00", sunset: "2026-03-20T18:15:00", timestamp: "2026-03-23T10:00:00Z" ) manager.saveSunTimes(sunTimes) let retrieved = manager.getSunTimes() XCTAssertNotNil(retrieved) XCTAssertEqual(retrieved?.date, "2026-03-20") } func testClearData() { let location = AppGroupManager.UserLocation( latitude: 0.0, longitude: 0.0, timestamp: "2026-03-23T10:00:00Z", isDefaultLocation: true ) manager.saveLocation(location) manager.clearAllData() XCTAssertNil(manager.getLocation()) } } ``` - [ ] **Step 3: Run tests** Run: `xcodebuild -scheme SolverVTests -destination 'generic/platform=iOS' test` Expected: Tests pass - [ ] **Step 4: Commit** ```bash git add Solverv/Utilities/AppGroupManager.swift SolverVTests/Utilities/AppGroupManagerTests.swift git commit -m "feat: add AppGroupManager for widget-app data syncing" ``` --- ### Task 5.5: Verify SolvervDef Has Required Methods **Files:** - Modify: `Solsnu.Widget/Solsnu_Widget.swift` - [ ] **Step 1: Check SolvervDef methods exist** In Xcode, open `Solsnu.Widget/Solsnu_Widget.swift` and verify these methods exist on `SolvervDef`: - `nextEvent()` → SolsticeEvent? - `daysUntilNext()` → Int - `progressRatio()` → Double - `upcomingEventsPreview(count: Int)` → [SolsticeEvent] - `season` → Season If missing, add stub methods: ```swift extension SolvervDef { var season: Season { // Will be implemented in Task 7 return .winter } func daysUntilNext() -> Int { return 0 } func progressRatio() -> Double { return 0.0 } func upcomingEventsPreview(count: Int) -> [SolsticeEvent] { return [] } } ``` - [ ] **Step 2: Run intermediate build** Run: `xcodebuild -scheme Solsnu_Widget -destination 'generic/platform=iOS' build` Expected: Build succeeds (allows widget code to compile before full implementation) - [ ] **Step 3: Commit** ```bash git add Solsnu.Widget/Solsnu_Widget.swift git commit -m "chore: add SolvervDef stub methods for widget integration" ``` --- ### Task 6: Create Widget Views (Small, Medium, Large) **Files:** - Create: `Solsnu.Widget/Views/SmallWidgetView.swift` - Create: `Solsnu.Widget/Views/MediumWidgetView.swift` - Create: `Solsnu.Widget/Views/LargeWidgetView.swift` - [ ] **Step 1: Write SmallWidgetView** ```swift // Solsnu.Widget/Views/SmallWidgetView.swift import SwiftUI import WidgetKit struct SmallWidgetView: View { let entry: SolvervEntry @Environment(\.widgetRenderingMode) var renderingMode var body: some View { VStack(spacing: 8) { // Seasonal image Image(entry.def.season.assetName) .resizable() .scaledToFit() .frame(maxHeight: .infinity) // Event name Text(entry.def.nextEvent?.name ?? "Loading...") .font(.caption) .lineLimit(1) // Days countdown Text("\(entry.def.daysUntilNext())") .font(.system(.title, design: .default).weight(.bold)) .foregroundColor(entry.def.season.colorLight) Text("days") .font(.caption2) } .padding() } } #Preview(as: .systemSmall) { let entry = SolvervEntry(def: SolvervDef.preview) SmallWidgetView(entry: entry) } ``` - [ ] **Step 2: Write MediumWidgetView** ```swift // Solsnu.Widget/Views/MediumWidgetView.swift import SwiftUI import WidgetKit struct MediumWidgetView: View { let entry: SolvervEntry var body: some View { HStack(spacing: 12) { // Left: Image Image(entry.def.season.assetName) .resizable() .scaledToFit() .frame(width: 120, height: 120) // Right: Info VStack(alignment: .leading, spacing: 8) { Text(entry.def.nextEvent?.name ?? "Loading...") .font(.headline) .lineLimit(1) Text("\(entry.def.daysUntilNext()) days") .font(.system(.title3, design: .default).weight(.semibold)) .foregroundColor(entry.def.season.colorLight) ProgressView(value: Double(entry.def.progressRatio())) .tint(entry.def.season.colorLight) Spacer() } Spacer() } .padding() } } #Preview(as: .systemMedium) { let entry = SolvervEntry(def: SolvervDef.preview) MediumWidgetView(entry: entry) } ``` - [ ] **Step 3: Write LargeWidgetView** ```swift // Solsnu.Widget/Views/LargeWidgetView.swift import SwiftUI import WidgetKit struct LargeWidgetView: View { let entry: SolvervEntry var body: some View { VStack(spacing: 12) { // Top: Image Image(entry.def.season.assetName) .resizable() .scaledToFill() .frame(height: 140) .clipped() // Bottom: Info VStack(alignment: .leading, spacing: 10) { // Event name and countdown VStack(alignment: .leading, spacing: 4) { Text(entry.def.nextEvent?.name ?? "Loading...") .font(.headline) Text("\(entry.def.daysUntilNext()) days") .font(.system(.title2, design: .default).weight(.bold)) .foregroundColor(entry.def.season.colorLight) } // Progress bar ProgressView(value: Double(entry.def.progressRatio())) .tint(entry.def.season.colorLight) Divider() // Next 3 events preview VStack(alignment: .leading, spacing: 6) { Text("Upcoming") .font(.caption) .foregroundColor(.secondary) ForEach(entry.def.upcomingEventsPreview(count: 3), id: \.id) { event in HStack { Circle() .fill(event.season.colorLight) .frame(width: 8, height: 8) Text(event.name) .font(.caption) Spacer() Text("\(event.daysUntil())d") .font(.caption2) .foregroundColor(.secondary) } } } Spacer() } .padding(.horizontal) .padding(.vertical, 8) } } } #Preview(as: .systemLarge) { let entry = SolvervEntry(def: SolvervDef.preview) LargeWidgetView(entry: entry) } ``` - [ ] **Step 4: Verify widgets compile** Run: `xcodebuild -scheme Solsnu_Widget -destination 'generic/platform=iOS' build` Expected: Build succeeds - [ ] **Step 5: Commit** ```bash git add Solsnu.Widget/Views/ git commit -m "feat: add widget views for small, medium, and large sizes" ``` --- ### Task 7: Update SolvervDef and Widget Provider **Files:** - Modify: `Solsnu.Widget/Solsnu_Widget.swift` - [ ] **Step 1: Update SolvervDef with required methods** Add to `SolvervDef` in `Solsnu_Widget.swift`: ```swift extension SolvervDef { var season: Season { guard let next = nextEvent else { return .winter } return next.season } var nextEvent: SolsticeEvent? { SolsticeData.shared.nextEvent() } func daysUntilNext() -> Int { guard let next = nextEvent else { return 0 } return next.daysUntil() } func progressRatio() -> Double { 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) } static var preview: SolvervDef { SolvervDef(date: Date()) } } ``` - [ ] **Step 2: Commit** ```bash git add Solsnu.Widget/Solsnu_Widget.swift git commit -m "feat: extend SolvervDef with solstice calculations" ``` --- ### Task 8: Create Main App Info Screen **Files:** - Create: `Solverv/Views/InfoScreenView.swift` - Modify: `Solverv/ContentView.swift` - [ ] **Step 1: Write InfoScreenView** ```swift // Solverv/Views/InfoScreenView.swift import SwiftUI struct InfoScreenView: View { @State private var upcomingEvents: [SolsticeEvent] = [] @State private var nextEvent: SolsticeEvent? @State private var progressData: (elapsed: Int, total: Int)? = nil @State private var sunriseTime: Date? @State private var sunsetTime: Date? var body: some View { ScrollView { VStack(spacing: 24) { // Top section: Image + countdown VStack(spacing: 16) { if let next = nextEvent { Image(next.season.assetName) .resizable() .scaledToFit() .frame(height: 200) .clipped() VStack(spacing: 8) { Text(next.name) .font(.title3) Text("\(next.daysUntil())") .font(.system(.title, design: .default).weight(.bold)) .foregroundColor(next.season.colorLight) Text("days remaining") .font(.caption) .foregroundColor(.secondary) } if let progress = progressData { ProgressView(value: Double(progress.elapsed) / Double(progress.total)) .tint(next.season.colorLight) } } } .padding() .background(Color(.systemGray6)) .cornerRadius(12) // Middle section: Sun times + season info VStack(spacing: 16) { VStack(alignment: .leading, spacing: 12) { Text("Today's Sun Times") .font(.headline) HStack { Label("Sunrise", systemImage: "sunrise.fill") Spacer() if let sunrise = sunriseTime { Text(sunrise, style: .time) } else { Text("—").foregroundColor(.secondary) } } HStack { Label("Sunset", systemImage: "sunset.fill") Spacer() if let sunset = sunsetTime { Text(sunset, style: .time) } else { Text("—").foregroundColor(.secondary) } } } Divider() if let next = nextEvent { VStack(alignment: .leading, spacing: 8) { Text("Current Season") .font(.headline) HStack(spacing: 8) { Circle() .fill(next.season.colorLight) .frame(width: 12, height: 12) VStack(alignment: .leading, spacing: 4) { Text(next.season.displayName) .font(.subheadline) Text(next.season.description) .font(.caption) .foregroundColor(.secondary) } } } } } .padding() .background(Color(.systemGray6)) .cornerRadius(12) // Bottom section: Upcoming events list VStack(alignment: .leading, spacing: 12) { Text("Upcoming Events") .font(.headline) ForEach(upcomingEvents, id: \.id) { event in HStack { VStack(alignment: .leading, spacing: 2) { Text(event.name) .font(.subheadline) .lineLimit(1) let formatter = DateFormatter() formatter.dateStyle = .short formatter.timeStyle = .short formatter.timeZone = .current Text(formatter.string(from: event.localDateTime())) .font(.caption) .foregroundColor(.secondary) } Spacer() VStack(alignment: .trailing, spacing: 2) { Text("\(event.daysUntil())d") .font(.subheadline) Circle() .fill(event.season.colorLight) .frame(width: 12, height: 12) } } .padding(.vertical, 8) if event.id != upcomingEvents.last?.id { Divider() } } } .padding() .background(Color(.systemGray6)) .cornerRadius(12) } .padding() } .navigationTitle("Solstices & Equinoxes") .onAppear(perform: loadData) .onReceive(Timer.publish(every: 60).autoconnect()) { _ in loadData() } } private func loadData() { nextEvent = SolsticeData.shared.nextEvent() upcomingEvents = SolsticeData.shared.upcomingEvents(count: 12) progressData = SolsticeData.shared.progressToNextEvent() // Load sunrise/sunset from AppGroup or calculate if let location = AppGroupManager.shared.getLocation() { let sunTimes = SunTimes( latitude: location.latitude, longitude: location.longitude, date: Date() ) sunriseTime = sunTimes.sunrise() sunsetTime = sunTimes.sunset() } } } #Preview { NavigationStack { InfoScreenView() } } ``` - [ ] **Step 2: Update ContentView to include InfoScreen in navigation** Replace `ContentView.swift`: ```swift // Solverv/ContentView.swift import SwiftUI struct ContentView: View { var body: some View { NavigationStack { VStack { Text("Solstice Countdown") .font(.title) NavigationLink(destination: InfoScreenView()) { Label("View Details", systemImage: "info.circle") .padding() .background(Color.blue) .foregroundColor(.white) .cornerRadius(8) } } .navigationTitle("Home") } } } #Preview { ContentView() } ``` - [ ] **Step 3: Verify compilation** Run: `xcodebuild -scheme Solverv -destination 'generic/platform=iOS' build` Expected: Build succeeds - [ ] **Step 4: Commit** ```bash git add Solverv/Views/InfoScreenView.swift Solverv/ContentView.swift git commit -m "feat: add InfoScreenView with upcoming events and sun times" ``` --- ### Task 9: Request Location Permission in App Delegate **Files:** - Modify: `Solverv/SolvervApp.swift` - [ ] **Step 1: Update SolvervApp to request location permission** ```swift // Solverv/SolvervApp.swift import SwiftUI import SwiftData import CoreLocation @main struct SolvervApp: App { @State private var locationManager = LocationManager() var sharedModelContainer: ModelContainer = { let schema = Schema([Item.self]) let modelConfiguration = ModelConfiguration(schema: schema, isStoredInMemoryOnly: false) do { return try ModelContainer(for: schema, configurations: [modelConfiguration]) } catch { fatalError("Could not create ModelContainer: \(error)") } }() var body: some Scene { WindowGroup { ContentView() } .modelContainer(sharedModelContainer) .onAppear { locationManager.requestLocationPermission() } } } class LocationManager: NSObject, CLLocationManagerDelegate, ObservableObject { let manager = CLLocationManager() override init() { super.init() manager.delegate = self } func requestLocationPermission() { if manager.authorizationStatus == .notDetermined { manager.requestWhenInUseAuthorization() } else if manager.authorizationStatus == .authorizedWhenInUse || manager.authorizationStatus == .authorizedAlways { fetchLocation() } else { // Use default location (Greenwich) let defaultLocation = AppGroupManager.UserLocation( latitude: 0.0, longitude: 0.0, timestamp: ISO8601DateFormatter().string(from: Date()), isDefaultLocation: true ) AppGroupManager.shared.saveLocation(defaultLocation) } } func locationManager(_ manager: CLLocationManager, didChangeAuthorization status: CLAuthorizationStatus) { if status == .authorizedWhenInUse || status == .authorizedAlways { fetchLocation() } } private func fetchLocation() { manager.startUpdatingLocation() } func locationManager(_ manager: CLLocationManager, didUpdateLocations locations: [CLLocation]) { guard let location = locations.last else { return } let userLocation = AppGroupManager.UserLocation( latitude: location.coordinate.latitude, longitude: location.coordinate.longitude, timestamp: ISO8601DateFormatter().string(from: Date()), isDefaultLocation: false ) AppGroupManager.shared.saveLocation(userLocation) // Calculate and cache sunrise/sunset let sunTimes = SunTimes( latitude: location.coordinate.latitude, longitude: location.coordinate.longitude, date: Date() ) if let sunrise = sunTimes.sunrise(), let sunset = sunTimes.sunset() { let formatter = ISO8601DateFormatter() let cachedTimes = AppGroupManager.SunTimes( date: formatter.string(from: Date()).prefix(10).description, sunrise: formatter.string(from: sunrise), sunset: formatter.string(from: sunset), timestamp: formatter.string(from: Date()) ) AppGroupManager.shared.saveSunTimes(cachedTimes) } manager.stopUpdatingLocation() } } ``` - [ ] **Step 2: Add location permission description to Info.plist** In Xcode, open `Solverv/Info.plist` and add: - Key: `NSLocationWhenInUseUsageDescription` - Value: `"We need your location to calculate sunrise and sunset times for your area."` - [ ] **Step 3: Verify compilation** Run: `xcodebuild -scheme Solverv -destination 'generic/platform=iOS' build` Expected: Build succeeds - [ ] **Step 4: Commit** ```bash git add Solverv/SolvervApp.swift Solverv/Info.plist git commit -m "feat: add location permission request and caching" ``` --- ### Task 10: Add Image Assets to Xcode Project **Files:** - Create: Asset images in `Solverv/Assets.xcassets` - [ ] **Step 1: Create Color Sets in Assets.xcassets** In Xcode: 1. Open `Solverv/Assets.xcassets` 2. Click `+` at bottom → "New Color Set" 3. Create 4 color sets with names: `SeasonSpring`, `SeasonSummer`, `SeasonAutumn`, `SeasonWinter` 4. For each, set the color: - Spring: RGB (76, 175, 80) - Summer: RGB (255, 193, 7) - Autumn: RGB (255, 152, 0) - Winter: RGB (33, 150, 243) 5. Enable dark mode variants in Attributes Inspector 6. Set dark mode colors to a slightly adjusted version or same color Alternative: Create placeholder image sets with simple colored squares: - Each should be 1024×1024 PNG - Name: `SeasonSpring`, `SeasonSummer`, `SeasonAutumn`, `SeasonWinter` - Include 1x, 2x, 3x variants - [ ] **Step 2: Commit** ```bash git add Solverv/Assets.xcassets/ git commit -m "feat: add season image assets for widget and app" ``` --- ### Task 11: Update Widget Bundle and Configure Multiple Sizes **Files:** - Modify: `Solsnu.Widget/Solsnu_WidgetBundle.swift` - [ ] **Step 1: Update widget bundle to support all sizes** ```swift // Solsnu.Widget/Solsnu_WidgetBundle.swift import WidgetKit import SwiftUI @main struct Solsnu_WidgetBundle { var body: some Widget { Solsnu_Widget() Solsnu_WidgetMedium() Solsnu_WidgetLarge() } } // Small widget (default) struct Solsnu_Widget: Widget { let kind: String = "Solsnu_Widget_Small" var body: some WidgetConfiguration { StaticConfiguration(kind: kind, provider: Provider()) { entry in if #available(iOS 17.0, *) { SmallWidgetView(entry: entry) .containerBackground(.fill.tertiary, for: .widget) } else { SmallWidgetView(entry: entry) .padding() .background() } } .configurationDisplayName("Solstice Countdown") .description("Days until next solstice or equinox") .supportedFamilies([.systemSmall]) } } // Medium widget struct Solsnu_WidgetMedium: Widget { let kind: String = "Solsnu_Widget_Medium" var body: some WidgetConfiguration { StaticConfiguration(kind: kind, provider: Provider()) { entry in if #available(iOS 17.0, *) { MediumWidgetView(entry: entry) .containerBackground(.fill.tertiary, for: .widget) } else { MediumWidgetView(entry: entry) .padding() .background() } } .configurationDisplayName("Solstice Countdown") .description("Days until next solstice or equinox") .supportedFamilies([.systemMedium]) } } // Large widget struct Solsnu_WidgetLarge: Widget { let kind: String = "Solsnu_Widget_Large" var body: some WidgetConfiguration { StaticConfiguration(kind: kind, provider: Provider()) { entry in if #available(iOS 17.0, *) { LargeWidgetView(entry: entry) .containerBackground(.fill.tertiary, for: .widget) } else { LargeWidgetView(entry: entry) .padding() .background() } } .configurationDisplayName("Solstice Countdown") .description("Days until next solstice or equinox") .supportedFamilies([.systemLarge]) } } ``` - [ ] **Step 2: Verify compilation** Run: `xcodebuild -scheme Solsnu_Widget -destination 'generic/platform=iOS' build` Expected: Build succeeds - [ ] **Step 3: Commit** ```bash git add Solsnu.Widget/Solsnu_WidgetBundle.swift git commit -m "feat: add medium and large widget configurations" ``` --- ### Task 12: Update Widget Provider Timeline Refresh **Files:** - Modify: `Solsnu.Widget/Solsnu_Widget.swift` (Provider section) - [ ] **Step 1: Update Provider to use midnight refresh** Replace the `Provider` struct in `Solsnu_Widget.swift`: ```swift struct Provider: TimelineProvider { func placeholder(in context: Context) -> SolvervEntry { SolvervEntry(def: SolvervDef(date: Date())) } func getSnapshot(in context: Context, completion: @escaping (SolvervEntry) -> ()) { let entry = SolvervEntry(def: SolvervDef(date: Date())) completion(entry) } func getTimeline(in context: Context, completion: @escaping (Timeline) -> ()) { var entries: [SolvervEntry] = [] let currentDate = Date() // 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) } } ``` - [ ] **Step 2: Verify compilation** Run: `xcodebuild -scheme Solsnu_Widget -destination 'generic/platform=iOS' build` Expected: Build succeeds - [ ] **Step 3: Commit** ```bash git add Solsnu.Widget/Solsnu_Widget.swift git commit -m "feat: update widget timeline to refresh at midnight" ``` --- ### Task 13: Run All Tests and Verify Build **Files:** - All modified files - [ ] **Step 1: Run all unit tests** Run: `xcodebuild -scheme SolverVTests -destination 'generic/platform=iOS' test` Expected: All tests pass (SolsticeDataTests, SunTimesTests, AppGroupManagerTests) - [ ] **Step 2: Build main app** Run: `xcodebuild -scheme Solverv -destination 'generic/platform=iOS' build` Expected: Build succeeds - [ ] **Step 3: Build widget** Run: `xcodebuild -scheme Solsnu_Widget -destination 'generic/platform=iOS' build` Expected: Build succeeds - [ ] **Step 4: Commit test results** ```bash git add -A git commit -m "test: verify all tests pass and builds succeed" ``` --- ## Summary This plan delivers: ✅ **Core Models:** SolsticeEvent, SolsticeData, Season with full calculations ✅ **Utilities:** SunTimes (NOAA algorithm), AppGroupManager for data syncing ✅ **Widget Views:** Small (169×169), Medium (364×169), Large (364×364) with responsive layouts ✅ **Main App:** InfoScreenView showing upcoming events, sun times, progress ✅ **Location Permission:** Automatic request on launch with default fallback ✅ **Image Assets:** Season-specific imagery in dark/light modes ✅ **Data Persistence:** AppGroup container for widget-app sync ✅ **Testing:** Unit tests for core calculations with expected behavior verification ✅ **Timeline Refresh:** Widget refreshes daily at midnight **Total Tasks:** 13 logical implementation steps **Estimated Commits:** ~20 (one per meaningful change) ---