summaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
-rw-r--r--docs/superpowers/plans/2026-03-23-solstice-widget-implementation.md1500
1 files changed, 1500 insertions, 0 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
new file mode 100644
index 0000000..e98dc55
--- /dev/null
+++ b/docs/superpowers/plans/2026-03-23-solstice-widget-implementation.md
@@ -0,0 +1,1500 @@
+# 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 SunTimes class (NOAA algorithm)**
+
+```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
+ // 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
+ }
+
+ // Convert date to day of year
+ var daysInMonth = [31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31]
+ if isLeapYear(year) { daysInMonth[1] = 29 }
+
+ var dayOfYear = day
+ for m in 1..<month {
+ dayOfYear += daysInMonth[m - 1]
+ }
+
+ // Fractional year in radians
+ let gamma = 2.0 * Double.pi * Double(dayOfYear - 1) / Double(isLeapYear(year) ? 366 : 365)
+
+ // Solar declination
+ 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)
+
+ // 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))
+
+ // Hour angle
+ let latRad = latitude * Double.pi / 180.0
+ let cosH = -tan(latRad) * tan(decl)
+
+ guard cosH >= -1 && cosH <= 1 else {
+ // Sun is always up or always down
+ return nil
+ }
+
+ let h = acos(cosH) * 180.0 / Double.pi
+
+ // Solar noon (local solar time)
+ let solarNoon = 12.0 - longitude / 15.0 - eot / 60.0
+
+ // Sunrise and sunset (local solar time)
+ let sunriseTime = solarNoon - h / 15.0
+ let sunsetTime = solarNoon + h / 15.0
+
+ // Convert local solar time to standard time
+ let timeZoneOffset = Double(TimeZone.current.secondsFromGMT(for: date)) / 3600.0
+ let adjustedSunrise = sunriseTime + timeZoneOffset - longitude / 15.0
+ let adjustedSunset = sunsetTime + timeZoneOffset - longitude / 15.0
+
+ // Create date objects
+ var sunriseComponents = calendar.dateComponents([.year, .month, .day], from: date)
+ sunriseComponents.hour = Int(adjustedSunrise)
+ sunriseComponents.minute = Int((adjustedSunrise.truncatingRemainder(dividingBy: 1)) * 60)
+ sunriseComponents.second = 0
+
+ var sunsetComponents = calendar.dateComponents([.year, .month, .day], from: date)
+ sunsetComponents.hour = Int(adjustedSunset)
+ sunsetComponents.minute = Int((adjustedSunset.truncatingRemainder(dividingBy: 1)) * 60)
+ sunsetComponents.second = 0
+
+ guard let sunriseDate = calendar.date(from: sunriseComponents),
+ let sunsetDate = calendar.date(from: sunsetComponents) else {
+ return nil
+ }
+
+ return (sunriseDate, sunsetDate)
+ }
+
+ private func isLeapYear(_ year: Int) -> Bool {
+ return (year % 4 == 0 && year % 100 != 0) || (year % 400 == 0)
+ }
+}
+```
+
+- [ ] **Step 2: Write test for sunrise/sunset calculations**
+
+```swift
+// SolverVTests/Utilities/SunTimesTests.swift
+import XCTest
+@testable import Solverv
+
+final class SunTimesTests: XCTestCase {
+ // Test with known location: Oslo (59.9139°N, 10.7522°E)
+ // on March 20, 2026 (around spring equinox)
+
+ func testSunriseSunsetReturnsValidDates() {
+ let dateComponents = DateComponents(year: 2026, month: 3, day: 20)
+ let date = Calendar.current.date(from: dateComponents)!
+
+ let sunTimes = SunTimes(latitude: 59.9139, longitude: 10.7522, date: date)
+ let sunrise = sunTimes.sunrise()
+ let sunset = sunTimes.sunset()
+
+ XCTAssertNotNil(sunrise)
+ XCTAssertNotNil(sunset)
+ XCTAssertLessThan(sunrise!, sunset!)
+ }
+
+ func testSunriseSunsetReasonsableForEquinox() {
+ let dateComponents = DateComponents(year: 2026, month: 3, day: 20)
+ 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("Should have sunrise/sunset")
+ return
+ }
+
+ // On equinox, day length should be ~12 hours
+ let dayLength = Calendar.current.dateComponents([.minute], from: sunrise, to: sunset).minute ?? 0
+ XCTAssertGreaterThan(dayLength, 720 - 60) // Allow ±1 hour variance
+ XCTAssertLessThan(dayLength, 720 + 60)
+ }
+}
+```
+
+- [ ] **Step 3: Run tests**
+
+Run: `xcodebuild -scheme SolverVTests -destination 'generic/platform=iOS' test`
+Expected: Tests pass
+
+- [ ] **Step 4: Commit**
+
+```bash
+git add Solverv/Utilities/SunTimes.swift SolverVTests/Utilities/SunTimesTests.swift
+git commit -m "feat: add SunTimes calculator using NOAA algorithm"
+```
+
+---
+
+### 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 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)
+
+---