diff options
| author | ivar <i@oiee.no> | 2026-05-07 01:24:28 +0200 |
|---|---|---|
| committer | ivar <i@oiee.no> | 2026-05-07 01:24:28 +0200 |
| commit | 6eb17a18e901e2d7faa219d7e5a79083a5891dc9 (patch) | |
| tree | 3d0796e1e567864dfdf7c675f7e8a5a40fb51a95 /docs/superpowers/plans/2026-03-23-solstice-widget-implementation.md | |
| parent | 4fb690150b77afced6453e6bdb14cc4cf00d5305 (diff) | |
| download | solverv-master.tar.xz solverv-master.zip | |
Diffstat (limited to 'docs/superpowers/plans/2026-03-23-solstice-widget-implementation.md')
| -rw-r--r-- | docs/superpowers/plans/2026-03-23-solstice-widget-implementation.md | 1606 |
1 files changed, 0 insertions, 1606 deletions
diff --git a/docs/superpowers/plans/2026-03-23-solstice-widget-implementation.md b/docs/superpowers/plans/2026-03-23-solstice-widget-implementation.md deleted file mode 100644 index 78e8b36..0000000 --- a/docs/superpowers/plans/2026-03-23-solstice-widget-implementation.md +++ /dev/null @@ -1,1606 +0,0 @@ -# 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<Entry>) -> ()) { - 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) - ---- |
