diff options
| author | ivar <i@oiee.no> | 2026-05-07 01:24:28 +0200 |
|---|---|---|
| committer | ivar <i@oiee.no> | 2026-05-07 01:24:28 +0200 |
| commit | 6eb17a18e901e2d7faa219d7e5a79083a5891dc9 (patch) | |
| tree | 3d0796e1e567864dfdf7c675f7e8a5a40fb51a95 /docs/superpowers/plans | |
| parent | 4fb690150b77afced6453e6bdb14cc4cf00d5305 (diff) | |
| download | solverv-master.tar.xz solverv-master.zip | |
Diffstat (limited to 'docs/superpowers/plans')
| -rw-r--r-- | docs/superpowers/plans/2026-03-23-solstice-widget-implementation.md | 1606 | ||||
| -rw-r--r-- | docs/superpowers/plans/2026-03-23-sunrise-sunset-widget-implementation.md | 539 |
2 files changed, 0 insertions, 2145 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. - |
