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 | |
| parent | 4fb690150b77afced6453e6bdb14cc4cf00d5305 (diff) | |
| download | solverv-master.tar.xz solverv-master.zip | |
Diffstat (limited to 'docs/superpowers')
4 files changed, 0 insertions, 2604 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) - ---- diff --git a/docs/superpowers/plans/2026-03-23-sunrise-sunset-widget-implementation.md b/docs/superpowers/plans/2026-03-23-sunrise-sunset-widget-implementation.md deleted file mode 100644 index 19c36fb..0000000 --- a/docs/superpowers/plans/2026-03-23-sunrise-sunset-widget-implementation.md +++ /dev/null @@ -1,539 +0,0 @@ -# Sunrise/Sunset 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:** Display sunrise and sunset times in HH:mm format on small and medium widgets, calculated locally using the user's location. - -**Architecture:** Widget Provider fetches cached user location from AppGroupManager, calculates sunrise/sunset times using the existing SunTimes utility, and passes formatted times to widget views. Gracefully handles unavailable locations by showing no times. - -**Tech Stack:** -- SwiftUI WidgetKit (existing) -- SunTimes (NOAA algorithm, already available) -- AppGroupManager (existing shared storage) - ---- - -## File Structure - -**Files to Modify:** -- `Solsnu.Widget/Solsnu_Widget.swift` — Add time properties to SolvervDef, update Provider logic -- `Solsnu.Widget/Views/SmallWidgetView.swift` — Display sunrise/sunset if available -- `Solsnu.Widget/Views/MediumWidgetView.swift` — Display sunrise/sunset if available - -**Files to Create:** -- None (all infrastructure already exists) - ---- - -## Task 1: Add Properties to SolvervDef - -**Files:** -- Modify: `Solsnu.Widget/Solsnu_Widget.swift:72-90` - -- [ ] **Step 1: Add sunrise/sunset Date properties to SolvervDef struct** - -Add these properties after `bg: String`: -```swift -let sunriseTime: Date? -let sunsetTime: Date? -``` - -- [ ] **Step 2: Update existing constructors to accept nil times** - -Modify `init(date:Date)`: -```swift -init(date: Date, sunriseTime: Date? = nil, sunsetTime: Date? = nil) { - self.date = date - self.sunriseTime = sunriseTime - self.sunsetTime = sunsetTime - self.bg = "smallbg" -} -``` - -Modify `init(utcString:String)`: -```swift -init(utcString: String, sunriseTime: Date? = nil, sunsetTime: Date? = nil) { - let formatter = DateFormatter() - formatter.dateFormat = "yyyy-MM-dd HH:mm:ss" - formatter.timeZone = TimeZone(abbreviation: "UTC") - let date = formatter.date(from: utcString)! - self.date = date - self.sunriseTime = sunriseTime - self.sunsetTime = sunsetTime - self.bg = "smallbg" -} -``` - -- [ ] **Step 3: Add computed properties for formatted times** - -Add after `bg: String` property declarations: -```swift -var sunriseFormatted: String { - guard let time = sunriseTime else { return "" } - let formatter = DateFormatter() - formatter.timeStyle = .short - formatter.dateStyle = .none - return formatter.string(from: time) -} - -var sunsetFormatted: String { - guard let time = sunsetTime else { return "" } - let formatter = DateFormatter() - formatter.timeStyle = .short - formatter.dateStyle = .none - return formatter.string(from: time) -} -``` - -- [ ] **Step 4: Verify compilation** - -Build the widget target: -```bash -xcodebuild build -scheme Solsnu_Widget -destination "generic/platform=iOS" -``` - -Expected: Build succeeds with no errors - -- [ ] **Step 5: Commit** - -```bash -git add Solsnu.Widget/Solsnu_Widget.swift -git commit -m "feat: add sunrise/sunset properties to SolvervDef" -``` - ---- - -## Task 2: Update Provider to Fetch and Calculate Sun Times - -**Files:** -- Modify: `Solsnu.Widget/Solsnu_Widget.swift:11-37` (Provider struct and getTimeline method) - -- [ ] **Step 1: Import AppGroupManager in widget** - -Add to imports at top of Solsnu_Widget.swift: -```swift -import WidgetKit -import SwiftUI -// Add this: -import Combine -``` - -Note: Check if you need to add AppGroupManager to the widget target. It may require adding the file to the widget's Build Phases. - -- [ ] **Step 2: Rewrite Provider.getTimeline() to fetch location** - -Replace the entire `getTimeline` method with: -```swift -func getTimeline(in context: Context, completion: @escaping (Timeline<SolvervEntry>) -> ()) { - var entries: [SolvervEntry] = [] - let currentDate = Date() - - // Fetch location from AppGroupManager - let location = AppGroupManager.shared.getLocation() - - var sunriseTime: Date? = nil - var sunsetTime: Date? = nil - - // If location exists and is less than 24 hours old, calculate sun times - if let location = location { - let locationFormatter = ISO8601DateFormatter() - if let locationTimestamp = locationFormatter.date(from: location.timestamp) { - let hoursSinceLocation = currentDate.timeIntervalSince(locationTimestamp) / 3600.0 - - if hoursSinceLocation < 24.0 { - // Calculate sun times using SunTimes utility - let calculator = SunTimes( - latitude: location.latitude, - longitude: location.longitude, - date: currentDate - ) - sunriseTime = calculator.sunrise() - sunsetTime = calculator.sunset() - } - } - } - - // Create entry with calculated times - let entry = SolvervEntry( - def: SolvervDef( - date: currentDate, - sunriseTime: sunriseTime, - sunsetTime: sunsetTime - ) - ) - entries.append(entry) - - // Refresh at next midnight - 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 3: Verify Provider needs SunTimes import** - -At the top of Solsnu_Widget.swift, verify you have: -```swift -import WidgetKit -import SwiftUI -``` - -Note: SunTimes is in the main app target. You may need to either: -- Move SunTimes to a shared framework, OR -- Copy/duplicate SunTimes into the widget target, OR -- Add SunTimes file to widget's Build Phases - -Check your project setup. If SunTimes isn't accessible to the widget, you'll need to add it to the widget's Build Phases. - -- [ ] **Step 4: Build and verify no errors** - -```bash -xcodebuild build -scheme Solsnu_Widget -destination "generic/platform=iOS" -``` - -Expected: Build succeeds - -- [ ] **Step 5: Commit** - -```bash -git add Solsnu.Widget/Solsnu_Widget.swift -git commit -m "feat: add location fetching and sun time calculation to widget provider" -``` - ---- - -## Task 3: Update SmallWidgetView to Display Times - -**Files:** -- Modify: `Solsnu.Widget/Views/SmallWidgetView.swift` - -- [ ] **Step 1: Understand current SmallWidgetView layout** - -Read the file to see how it currently displays the countdown - -- [ ] **Step 2: Add sunrise/sunset display** - -Update `body` to include times if available: - -```swift -var body: some View { - ZStack { - Image(entry.def.bg) - .resizable() - .scaledToFill() - - VStack(spacing: 8) { - // Days until next event (existing) - Text("\(entry.def.daysUntilNext())") - .font(.system(size: 26, weight: .bold, design: .serif)) - .foregroundStyle(Color(red: 0.152, green: 0.136, blue: 0.056)) - .italic() - - // Sunrise/Sunset times (new) - if !entry.def.sunriseFormatted.isEmpty && !entry.def.sunsetFormatted.isEmpty { - HStack(spacing: 4) { - Text("↑ \(entry.def.sunriseFormatted)") - Text("↓ \(entry.def.sunsetFormatted)") - } - .font(.system(size: 11, weight: .regular)) - .foregroundStyle(Color(red: 0.152, green: 0.136, blue: 0.056)) - } - } - .position(x: 50, y: 50) - } - .containerBackground(for: .widget, alignment: .center) { Color.clear } -} -``` - -- [ ] **Step 3: Build and preview** - -Build the widget: -```bash -xcodebuild build -scheme Solsnu_Widget -destination "generic/platform=iOS" -``` - -Expected: Build succeeds - -Check the preview in Xcode to see the new layout with times (if available) - -- [ ] **Step 4: Commit** - -```bash -git add Solsnu.Widget/Views/SmallWidgetView.swift -git commit -m "feat: display sunrise/sunset times in small widget" -``` - ---- - -## Task 4: Update MediumWidgetView to Display Times - -**Files:** -- Modify: `Solsnu.Widget/Views/MediumWidgetView.swift` - -- [ ] **Step 1: Read current MediumWidgetView** - -Understand its current structure - -- [ ] **Step 2: Add sunrise/sunset display** - -Add the times to the medium widget view. Example approach: - -```swift -var body: some View { - ZStack { - Image(entry.def.bg) - .resizable() - .scaledToFill() - - VStack(alignment: .leading, spacing: 12) { - // Existing countdown content - Text("\(entry.def.daysUntilNext())") - .font(.system(size: 32, weight: .bold, design: .serif)) - .foregroundStyle(Color(red: 0.152, green: 0.136, blue: 0.056)) - - // Sunrise/Sunset times - if !entry.def.sunriseFormatted.isEmpty && !entry.def.sunsetFormatted.isEmpty { - HStack { - Text("Sunrise: \(entry.def.sunriseFormatted)") - Spacer() - Text("Sunset: \(entry.def.sunsetFormatted)") - } - .font(.system(size: 12, weight: .regular)) - .foregroundStyle(Color(red: 0.152, green: 0.136, blue: 0.056)) - } - } - .padding() - } - .containerBackground(for: .widget, alignment: .topLeading) { Color.clear } -} -``` - -(Adjust layout to match your design preferences) - -- [ ] **Step 3: Build and preview** - -```bash -xcodebuild build -scheme Solsnu_Widget -destination "generic/platform=iOS" -``` - -Expected: Build succeeds - -- [ ] **Step 4: Commit** - -```bash -git add Solsnu.Widget/Views/MediumWidgetView.swift -git commit -m "feat: display sunrise/sunset times in medium widget" -``` - ---- - -## Task 5: Handle SunTimes Availability in Widget - -**Files:** -- Modify: `Solsnu.Widget/Solsnu_Widget.swift` (if SunTimes is not available) - -**Note:** This task only applies if SunTimes cannot be imported directly into the widget. If you got build errors in Task 2 Step 3, you need to copy SunTimes into the widget target. - -- [ ] **Step 1: Check if SunTimes is available in widget** - -Try to build: -```bash -xcodebuild build -scheme Solsnu_Widget -destination "generic/platform=iOS" 2>&1 | grep -i suntimes -``` - -If no errors: Skip to Step 4 -If error: Proceed to Step 2 - -- [ ] **Step 2: Copy SunTimes to widget target (if needed)** - -If build failed because SunTimes isn't accessible: - -Copy the file: -```bash -cp Solverv/Utilities/SunTimes.swift Solsnu.Widget/Utilities/SunTimes.swift -``` - -Create the Utilities folder in widget if needed: -```bash -mkdir -p Solsnu.Widget/Utilities -``` - -- [ ] **Step 3: Add SunTimes to widget target in Xcode** - -In Xcode: -1. Open the project -2. Select `Solsnu.Widget` target -3. Go to Build Phases → Compile Sources -4. Add `Solsnu.Widget/Utilities/SunTimes.swift` - -Or via command line: -```bash -# This is typically done in Xcode UI -``` - -Build again: -```bash -xcodebuild build -scheme Solsnu_Widget -destination "generic/platform=iOS" -``` - -Expected: Build succeeds - -- [ ] **Step 4: Commit (if changes made)** - -```bash -git add Solsnu.Widget/Utilities/SunTimes.swift Solsnu.Widget/Solsnu_Widget.swift -git commit -m "feat: add SunTimes calculator to widget target for sun time calculations" -``` - -If no changes needed, skip this commit. - ---- - -## Task 6: Integration Testing - -**Files:** -- Test: Solsnu_Widget preview in Xcode - -- [ ] **Step 1: Test with valid location** - -In Solsnu_Widget.swift preview, modify the preview to include sample sun times: - -```swift -#Preview(as: .systemSmall) { - Solsnu_Widget() -} timeline: { - let formatter = DateFormatter() - formatter.timeStyle = .short - formatter.dateStyle = .none - - // Sample entry with sun times - let springDate = Calendar.current.date(from: DateComponents(year: 2026, month: 3, day: 20))! - let sunriseTime = Calendar.current.date(bySettingHour: 7, minute: 30, second: 0, of: springDate)! - let sunsetTime = Calendar.current.date(bySettingHour: 19, minute: 45, second: 0, of: springDate)! - - SolvervEntry( - def: SolvervDef( - date: springDate, - sunriseTime: sunriseTime, - sunsetTime: sunsetTime - ) - ) -} -``` - -Build the preview: -```bash -xcodebuild build -scheme Solsnu_Widget -destination "generic/platform=iOS" -``` - -- [ ] **Step 2: Verify widget displays times correctly** - -In Xcode, view the canvas/preview and confirm: -- Sunrise time displays as HH:mm format -- Sunset time displays as HH:mm format -- Both times appear on the widget (layout may vary) - -- [ ] **Step 3: Test with nil times (no location)** - -Update preview to test with nil times: - -```swift -SolvervEntry( - def: SolvervDef( - date: springDate, - sunriseTime: nil, - sunsetTime: nil - ) -) -``` - -Build and verify: -- Widget displays without crashing -- No times are shown -- Layout remains clean (no empty space for times) - -- [ ] **Step 4: Test different locations** - -Manually verify with sample calculations: -- Oslo (59.9°N, 10.7°E): Should show reasonable spring times -- Equator (0°, 0°): Should show ~6am and ~6pm -- Near poles: May show nil (handled gracefully) - -- [ ] **Step 5: Commit testing changes (if any)** - -```bash -git add Solsnu.Widget/Solsnu_Widget.swift -git commit -m "test: add preview entries with sunrise/sunset times" -``` - ---- - -## Task 7: Final Integration & Verification - -**Files:** -- Verify: All modified files - -- [ ] **Step 1: Run full build** - -```bash -xcodebuild build -scheme Solverv -destination "generic/platform=iOS" -xcodebuild build -scheme Solsnu_Widget -destination "generic/platform=iOS" -``` - -Expected: Both schemes build successfully with no warnings - -- [ ] **Step 2: Check widget refresh behavior** - -The widget should: -- Refresh at midnight (existing policy maintained) -- Show sun times if location is available and fresh (< 24 hours) -- Show nothing for times if location is unavailable or stale - -This is verified automatically via the Provider logic. - -- [ ] **Step 3: Verify no regressions** - -Check that existing functionality still works: -- Countdown still displays -- Solstice events still calculate correctly -- Widget background image still loads -- Other widget configurations still work - -- [ ] **Step 4: Final commit and review** - -Review all changes: -```bash -git log --oneline -7 -``` - -Should see commits for: -1. Add sunrise/sunset properties to SolvervDef -2. Add location fetching and sun time calculation to widget provider -3. Display sunrise/sunset times in small widget -4. Display sunrise/sunset times in medium widget -5. (Optional) Add SunTimes to widget target -6. (Optional) Add preview entries - -All changes complete ✅ - ---- - -## Notes - -- **AppGroupManager Dependency:** The widget must be able to access AppGroupManager to read cached location. Ensure both the main app and widget targets have access to this file or share it via a framework. - -- **SunTimes Availability:** The SunTimes calculator must be available to the widget. Either ensure it's in a shared framework or copy it to the widget target. - -- **Timezone Handling:** SunTimes already handles timezone conversion internally. Times are in device local time. - -- **Stale Location:** If location is older than 24 hours, no sun times are displayed. This aligns with the widget's daily refresh policy. - -- **Privacy:** Widget cannot request location permission—must rely on main app providing location via AppGroupManager. - diff --git a/docs/superpowers/specs/2026-03-23-solstice-widget-design.md b/docs/superpowers/specs/2026-03-23-solstice-widget-design.md deleted file mode 100644 index d91f1f2..0000000 --- a/docs/superpowers/specs/2026-03-23-solstice-widget-design.md +++ /dev/null @@ -1,307 +0,0 @@ -# Solstice Countdown Widget & App Design - -**Date:** 2026-03-23 -**Project:** Solverv (Solstice Countdown) -**Status:** Design Approved - ---- - -## Overview - -Build an iOS app that displays a countdown to the next solstice or equinox, with season-specific imagery and detailed information. The app includes: -- **Widget Extension:** Compact countdown widget in small, medium, and large sizes -- **Main App:** Info screen showing expanded details including sunrise/sunset times and upcoming events - ---- - -## Requirements - -### Functional Requirements -1. Track all four annual solstices/equinoxes (Spring, Summer, Autumn, Winter) -2. Display countdown to next event in **days only** -3. Show season-specific images that change based on upcoming event -4. Calculate and display sunrise/sunset times for user's current location -5. Show expanded event details in main app (exact times, season descriptions, upcoming event previews) -6. Support multiple widget sizes (small, medium, large) - -### Non-Functional Requirements -- All calculations happen locally (no external APIs) -- Works offline after initial setup -- Respects location privacy with explicit permission -- Single location permission prompt (shared via AppGroup) -- Data synced between app and widget via AppGroup container - ---- - -## Architecture - -### Data Models - -**SolsticeEvent** -``` -- name: String (e.g., "Summer Solstice", "Spring Equinox") -- date: Date (UTC) -- season: Season (enum: spring, summer, autumn, winter) -- seasonDescription: String (brief description of the season) -``` - -**SolsticeData** -``` -- events: [SolsticeEvent] (hardcoded data, 2025-2030) -- nextEvent() -> SolsticeEvent (returns next upcoming event) -- allUpcoming(count: Int) -> [SolsticeEvent] (returns next N events) -- daysUntil(_ event: SolsticeEvent) -> Int -- progressToEvent(_ event: SolsticeEvent) -> (elapsed: Int, total: Int) -``` - -**SunTimes** -``` -- latitude: Double -- longitude: Double -- date: Date -- sunrise() -> Date -- sunset() -> Date -``` - -### Data Sharing - -**AppGroup Container:** Store user location and cached sunrise/sunset times using the container ID `group.com.ivarlovlie.solverv` - -**AppGroup Data Schema:** -Stored in UserDefaults using the `group.com.ivarlovlie.solverv` container. -```json -{ - "userLocation.latitude": Double (e.g., 59.9139), - "userLocation.longitude": Double (e.g., 10.7522), - "userLocation.timestamp": String (ISO 8601, e.g., "2026-03-23T10:30:00Z"), - "userLocation.isDefaultLocation": Boolean (true = Greenwich, false = user-granted location), - - "sunTimes.date": String (ISO 8601 date only, e.g., "2026-03-23"), - "sunTimes.sunrise": String (ISO 8601 datetime in local timezone, e.g., "2026-03-23T06:42:00"), - "sunTimes.sunset": String (ISO 8601 datetime in local timezone, e.g., "2026-03-23T18:15:00"), - "sunTimes.timestamp": String (ISO 8601, when calculated, e.g., "2026-03-23T10:30:00Z") -} -``` -All Date values stored as ISO 8601 strings for cross-process compatibility. - -- **Widget Timeline:** Updates at midnight (local time) using `timelineReloadPolicy: .after(nextMidnight)` -- Both app and widget read from the same AppGroup container for consistency -- Cache invalidation: Sunrise/sunset cache is recalculated if stored date differs from today or location changes - ---- - -## Widget Specification - -### Small Widget (169×169) -- **Layout:** Vertical stack - - Seasonal image (fills most of space) - - Event name (small caption) - - Countdown in days (large, bold text) -- **Refresh:** `timelineReloadPolicy: .after(nextMidnight)` where `nextMidnight` is calculated as: - ```swift - var calendar = Calendar.current - var components = calendar.dateComponents([.year, .month, .day], from: Date()) - 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)! - ``` - The widget refreshes daily at midnight local time, automatically accounting for DST transitions. -- **Purpose:** Quick glance at how many days remain - -### Medium Widget (364×169) -- **Layout:** Horizontal split - - Left: Seasonal image (square) - - Right: Vertical stack with event name, countdown (large), progress bar -- **Refresh:** `timelineReloadPolicy: .after(nextMidnight)` — refreshes at midnight local time to update countdown -- **Purpose:** Balance of visual and numeric information - -### Large Widget (364×364) -- **Layout:** Vertical stack - - Top half: Seasonal image - - Bottom half: Event name, countdown, progress bar, preview of next 3 upcoming events (mini list) -- **Refresh:** `timelineReloadPolicy: .after(nextMidnight)` — refreshes at midnight local time to update countdown -- **Purpose:** Comprehensive view with upcoming events preview - -### Images -- One image per season (spring, summer, autumn, winter) -- Sourced from Assets.xcassets -- Same image shown for all events in that season - -**Image Specifications:** -- **Asset Names:** `SeasonSpring`, `SeasonSummer`, `SeasonAutumn`, `SeasonWinter` (stored in Assets.xcassets) -- **Color Set Strategy:** Each season asset has two variants (light and dark mode) using Xcode's Color Set appearance settings -- **Aspect Ratio:** 1:1 (square) -- **Resolutions per variant:** - - 1x: 1024×1024 - - 2x: 2048×2048 - - 3x: 3072×3072 -- **Safe Area:** Ensure important visual content avoids outer 20-point margin (on a 1024×1024 base, keep content within 960×960 center area) -- **Format:** PNG with alpha channel -- **Fallback:** If image fails to load, display solid color matching the season (spring: #4CAF50, summer: #FFC107, autumn: #FF9800, winter: #2196F3) - ---- - -## Main App Info Screen - -### Top Section -- Seasonal image (landscape orientation friendly) -- Next event name (large) -- Countdown in days (very large, prominent) -- Progress bar showing days elapsed since the previous solstice/equinox divided by total days until next solstice/equinox - - Calculation: `(today - lastSolsticeDate) / (nextSolsticeDate - lastSolsticeDate)` - - Example: Winter Solstice was Dec 21, 2025 (passed). Spring Equinox is Mar 20, 2026 (89 days later). On Jan 21, 2026, progress is 31/89 days elapsed. - - The progress bar **resets to 0%** the moment a new solstice/equinox occurs - -### Middle Section -- **Today's Sun Times** - - Sunrise time - - Sunset time - - Calculated from device location -- **Season Info** - - Season name - - Brief description (e.g., "Spring Equinox — Day and night are approximately equal length") - -### Bottom Section -- **Upcoming Events List** (scrollable) - - Shows next 8-12 events - - Each row displays: - - Event name (e.g., "Spring Equinox") - - Date/Time in local timezone, 12-hour format with AM/PM (e.g., "Mar 20, 2026 2:46 PM") - - Days remaining as integer (e.g., "45 days") - - Season color indicator: 12pt circle matching the season's primary color (spring: green, summer: yellow, autumn: orange, winter: blue) - -### Navigation -- Tab bar or simple navigation to this screen -- Refresh button to manually update sunrise/sunset (in case location changed) - ---- - -## Time Zone & Location Handling - -### Location -- Request permission on first app launch -- Store latitude/longitude in AppGroup container -- Fall back to Greenwich/UTC (0°, 0°) if permission denied -- User can manually update location in app settings - -### Time Zones -- All solstice times stored in UTC (as they are now) -- Convert to user's local timezone for display -- Sunrise/sunset calculated for user's timezone and location - -### Sunrise/Sunset Algorithm -- Implement a simplified solar position algorithm based on the NOAA algorithm (https://github.com/NOAA-OWP/sunpy) -- No external APIs; algorithm is self-contained -- **Algorithm Reference:** NOAA Solar Position Algorithm - - Calculate solar declination using Spencer's formula - - Calculate equation of time for date - - Calculate hour angle at sunrise/sunset - - Convert to local solar time then UTC -- **Inputs:** latitude (Double), longitude (Double), date (Date in user's timezone) -- **Outputs:** sunrise (Date), sunset (Date) in local timezone -- **Cache:** Store in AppGroup container; recalculate daily or when location changes -- **Accuracy:** Results valid to ±2 minutes - ---- - -## Error Handling & Edge Cases - -### Location Permission -- App requests permission on first launch -- If denied, use default location (Greenwich) and notify user -- User can grant permission later in system Settings - -### Time Zone Edge Cases -- Solstice at midnight: Display correctly in both UTC and local time -- User crosses timezone: Times update automatically on app launch -- Widget timezone: Uses device timezone (set by system) - -### Data Integrity -- Solstice dates are hardcoded and immutable -- Sunrise/sunset cached but recalculated daily -- Widget syncs with app via AppGroup on launch - ---- - -## Testing Strategy - -### Widget Testing -- Preview all three widget sizes with mock solstice data -- Verify countdown updates correctly across timezone boundaries -- Test image display in different iOS versions (iOS 17 fallback) - -### App Testing -- Verify sunrise/sunset calculations against known values -- Test location permission flows (allowed, denied, not yet asked) -- Test data sync between app and widget -- Verify time zone conversions for various user locations - -### Integration Testing -- Widget refreshes daily and displays current countdown -- App and widget show consistent "next event" data -- Location changes update sunrise/sunset in real time - ---- - -## Implementation Notes - -### Existing Code -- `SolvervDef` already contains solstice dates (2025-2030) -- Widget structure (`Solsnu_Widget.swift`) is scaffolded -- Main app has basic SwiftUI structure ready for info screen - -### New Components to Build -- `SunTimes` calculation (sunrise/sunset) -- `SolsticeEvent` model -- Widget layout variants (small, medium, large) -- Info screen UI -- AppGroup data sharing - -### Dependencies -- None (all calculations are built-in or custom) -- WidgetKit (already available) -- SwiftUI (already used) - ---- - -## Additional Clarifications - -### Solstice Date Coverage -- Hardcoded data spans 2025–2030 -- After 2030, app continues functioning but upcoming events won't display beyond December 2030 -- Plan for data expansion before 2030: extend to 2040+ in an app update - -### Season Assignment -- The displayed season for an event is always the season being celebrated -- Example: Spring Equinox always displays spring imagery (green), even if shown during late winter on the calendar - -### Offline Functionality -- **First Launch:** - - App requests location permission - - If granted: Fetch location and calculate sunrise/sunset for that location, cache in AppGroup container - - If denied: Use default location (Greenwich, 0°/0°) and cache as `isDefaultLocation: true` - - All subsequent uses work offline -- **Subsequent Use:** - - App and widget work entirely offline; solstice dates and countdown calculations are all local - - Sunrise/sunset times use cached values from AppGroup container -- **State Recovery on Permission Changes:** - - If app is running when user changes location permission in Settings, recalculate sunrise/sunset on next app foreground event - - If AppGroup container is missing or corrupted on launch, fall back to Greenwich location and log error -- **Widget Behavior When Location Unavailable:** - - Widget always displays countdown (never fails) - - Sunrise/sunset times display as "—" if location permission denied and no cached data exists - ---- - -## Success Criteria - -✅ Widget displays countdown in days -✅ Seasonal image changes based on next event -✅ All four solstices/equinoxes tracked -✅ Sunrise/sunset times calculated from location -✅ Info screen shows all requested details -✅ App and widget data stay in sync -✅ Works offline after initial setup -✅ All three widget sizes render correctly diff --git a/docs/superpowers/specs/2026-03-23-sunrise-sunset-widget-design.md b/docs/superpowers/specs/2026-03-23-sunrise-sunset-widget-design.md deleted file mode 100644 index 1e4ed54..0000000 --- a/docs/superpowers/specs/2026-03-23-sunrise-sunset-widget-design.md +++ /dev/null @@ -1,152 +0,0 @@ -# Sunrise/Sunset Times Widget Feature - -**Date:** 2026-03-23 -**Status:** Design Approved - -## Overview - -Add sunrise and sunset time display to both small and medium widgets. Times are calculated locally using the existing NOAA solar position algorithm, formatted as HH:mm, and updated daily at midnight based on user location. - -## Requirements - -- Display sunrise/sunset times in HH:mm format on widgets -- Calculate times locally using existing `SunTimes` utility -- Use user's current location for calculations -- Update once daily at midnight (aligned with current refresh policy) -- Show nothing if location permission is denied or unavailable -- Apply to both small and medium widget families - -## Architecture - -### Data Model - -Extend `SolvervDef` to include sunrise/sunset times: -``` -SolvervDef -├── date: Date -├── bg: String -├── sunriseTime: Date? (optional) -├── sunsetTime: Date? (optional) -├── sunriseFormatted: String (computed) -└── sunsetFormatted: String (computed) -``` - -**Constructor signature:** -```swift -init(date: Date, sunriseTime: Date? = nil, sunsetTime: Date? = nil) { - self.date = date - self.sunriseTime = sunriseTime - self.sunsetTime = sunsetTime - self.bg = "smallbg" -} -``` - -The formatted properties extract HH:mm in device local time. - -### Timeline Provider Logic - -Update `Provider.getTimeline()`: - -1. Get user's cached location from `AppGroupManager` (widget must use App Group storage, not LocationManager) -2. Check if location is recent (less than 24 hours old); if stale/unavailable → create entry with nil sunrise/sunset -3. Initialize local `SunTimes(latitude, longitude, date: currentDate)` calculator -4. Extract sunrise and sunset times (already converted to device local time) -5. Create entry with times: `SolvervEntry(def: SolvervDef(date: currentDate, sunriseTime: sr, sunsetTime: ss))` -6. Return timeline refreshing at next midnight - -**Note:** Widget runs in separate process and cannot access main app's LocationManager. Location MUST come from AppGroupManager storage, populated by the main app. - -### Data Calculation Flow - -``` -Location Permission Check - ↓ (if granted) -Get Cached Coordinates - ↓ -SunTimes Calculator - ├─ Input: latitude, longitude, date - ├─ Output: sunrise Date, sunset Date - └─ Fallback: nil (polar regions, edge cases) - ↓ -Format as HH:mm - ↓ -SolvervDef/Entry - ↓ -Widget Display -``` - -### Error Handling - -| Scenario | Behavior | -|----------|----------| -| Location permission denied | No sunrise/sunset displayed | -| Location unavailable in AppGroupManager | No sunrise/sunset displayed | -| Location older than 24 hours | Treat as unavailable; no sunrise/sunset displayed | -| SunTimes returns nil (polar regions, 24h sun/darkness) | No sunrise/sunset displayed | - -### Timezone Handling - -- `SunTimes` internally converts to device's local timezone (uses `TimeZone.current`) -- Sunrise/sunset `Date` objects are in device local time -- Formatted display uses device local time (HH:mm) -- **Edge case:** If user changes timezone between midnight and next refresh, sun times remain valid for the previous timezone. They will update correctly at next midnight refresh (when new location may have different timezone). This is acceptable since widget only refreshes daily. - -## Implementation Details - -### Changes to SolvervDef - -Add properties: -- `sunriseTime: Date?` — raw sunrise time in device local timezone -- `sunsetTime: Date?` — raw sunset time in device local timezone - -Add computed properties for formatted times: -- `sunriseFormatted: String` — returns "HH:mm" format or empty string if nil -- `sunsetFormatted: String` — returns "HH:mm" format or empty string if nil - -Computed properties use device locale and 24-hour format. - -### Changes to Provider - -Modify `getTimeline()` to: -1. Fetch location from `AppGroupManager.getUserLocation()` — widget runs in separate process and must use App Group storage -2. Check timestamp: if location is older than 24 hours, treat as unavailable -3. If available, instantiate sunrise/sunset calculator: `SunTimes(latitude: lat, longitude: lon, date: currentDate)` from `/Solverv/Utilities/SunTimes.swift` -4. Call `calculator.sunrise()` and `calculator.sunset()` to get Date objects -5. Pass times to `SolvervDef(date: currentDate, sunriseTime: sr, sunsetTime: ss)` -6. Create entry and return timeline refreshing at next midnight (unchanged) - -### Widget View Updates - -**SmallWidgetView:** -- If sunrise/sunset available: display as "↑ HH:mm ↓ HH:mm" or similar compact format -- If unavailable: display nothing (no placeholder or empty space) - -**MediumWidgetView:** -- If sunrise/sunset available: display sunrise and sunset times in HH:mm format (exact layout TBD based on available space) -- If unavailable: display nothing (no placeholder or empty space) - -Both views: -- Access times via `entry.def.sunriseFormatted` and `entry.def.sunsetFormatted` -- Only render if both are non-empty strings - -## Timeline & Refresh - -- **Frequency:** Once per day at midnight -- **Rationale:** Sun times change gradually; daily updates are sufficient -- **Alignment:** Matches existing solstice countdown refresh policy - -## Testing - -- Test with various locations (tropics, temperate, near poles) -- Test without location permission -- Test at date boundaries (midnight refresh) -- Verify formatted times display correctly -- Verify nil times result in no display - -## Future Considerations - -- Visualization of sunrise/sunset (charts, gradients) -- Daylight duration calculation and trend -- Multiple locations support -- Custom location override - |
