diff options
Diffstat (limited to 'docs/superpowers/plans')
| -rw-r--r-- | docs/superpowers/plans/2026-03-23-solstice-widget-implementation.md | 1500 |
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) + +--- |
