summaryrefslogtreecommitdiffstats
path: root/Solsnu.Widget
diff options
context:
space:
mode:
Diffstat (limited to 'Solsnu.Widget')
-rw-r--r--Solsnu.Widget/Solsnu_Widget.swift234
-rw-r--r--Solsnu.Widget/Solsnu_WidgetBundle.swift21
-rw-r--r--Solsnu.Widget/Solsnu_WidgetControl.swift55
-rw-r--r--Solsnu.Widget/Views/MediumWidgetView.swift30
-rw-r--r--Solsnu.Widget/Views/SmallWidgetView.swift38
5 files changed, 128 insertions, 250 deletions
diff --git a/Solsnu.Widget/Solsnu_Widget.swift b/Solsnu.Widget/Solsnu_Widget.swift
index c36cc34..bc6fc0e 100644
--- a/Solsnu.Widget/Solsnu_Widget.swift
+++ b/Solsnu.Widget/Solsnu_Widget.swift
@@ -7,189 +7,129 @@
import WidgetKit
import SwiftUI
-import Foundation
+
+// MARK: - Provider
struct Provider: TimelineProvider {
func placeholder(in context: Context) -> SolvervEntry {
- SolvervEntry(def: SolvervDef(date: Date()))
+ .placeholder
}
- func getSnapshot(in context: Context, completion: @escaping (SolvervEntry) -> ()) {
- let entry = SolvervEntry(def: SolvervDef(date: Date()))
- completion(entry)
+ func getSnapshot(in context: Context, completion: @escaping (SolvervEntry) -> Void) {
+ completion(makeEntry(for: Date()))
}
- func getTimeline(in context: Context, completion: @escaping (Timeline<SolvervEntry>) -> ()) {
- var entries: [SolvervEntry] = []
- let currentDate = Date()
-
- // Fetch location from AppGroupManager
- var sunriseTime: Date? = nil
- var sunsetTime: Date? = nil
+ func getTimeline(in context: Context, completion: @escaping (Timeline<SolvervEntry>) -> Void) {
+ let entry = makeEntry(for: Date())
+ let nextMidnight = Calendar.current.nextDate(
+ after: Date(),
+ matching: DateComponents(hour: 0, minute: 0, second: 0),
+ matchingPolicy: .nextTime
+ ) ?? Date().addingTimeInterval(86_400)
+ completion(Timeline(entries: [entry], policy: .after(nextMidnight)))
+ }
- if let location = AppGroupManager.shared.getLocation() {
- // Check if location is fresh (< 24 hours old)
- let isoFormatter = ISO8601DateFormatter()
- if let locationTimestamp = isoFormatter.date(from: location.timestamp) {
- let hoursSinceCache = currentDate.timeIntervalSince(locationTimestamp) / 3600.0
+ private func makeEntry(for date: Date) -> SolvervEntry {
+ let progress = SolsticeData.shared.progressToNextEvent(from: date)
+ let ratio = progress.map {
+ max(0, min(1, Double($0.elapsed) / Double($0.total)))
+ } ?? 0
- if hoursSinceCache < 24.0 {
- // Location is fresh, calculate sun times
- let calculator = SunTimes(latitude: location.latitude, longitude: location.longitude, date: currentDate)
- sunriseTime = calculator.sunrise()
- sunsetTime = calculator.sunset()
- }
- }
+ let sunTimes = AppGroupManager.shared.getLocation().map {
+ SunTimes(latitude: $0.latitude, longitude: $0.longitude, date: date)
}
- let entry = SolvervEntry(def: SolvervDef(date: currentDate, sunriseTime: sunriseTime, sunsetTime: sunsetTime))
- entries.append(entry)
-
- 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)
+ return SolvervEntry(
+ date: date,
+ nextEvent: SolsticeData.shared.nextEvent(from: date),
+ currentEvent: SolsticeData.shared.currentEvent(from: date),
+ progressRatio: ratio,
+ sunriseTime: sunTimes?.sunrise(),
+ sunsetTime: sunTimes?.sunset()
+ )
}
}
+// MARK: - Entry
+
struct SolvervEntry: TimelineEntry {
let date: Date
- let def: SolvervDef
-
- init(def: SolvervDef, date: Date? = nil) {
- self.date = date ?? def.date
- self.def = def
+ let nextEvent: SolsticeEvent?
+ let currentEvent: SolsticeEvent?
+ let progressRatio: Double
+ let sunriseTime: Date?
+ let sunsetTime: Date?
+
+ static let placeholder = SolvervEntry(
+ date: Date(),
+ nextEvent: nil,
+ currentEvent: nil,
+ progressRatio: 0.4,
+ sunriseTime: nil,
+ sunsetTime: nil
+ )
+}
+
+// MARK: - Family dispatch
+
+struct SolsticeWidgetView: View {
+ let entry: SolvervEntry
+ @Environment(\.widgetFamily) var widgetFamily
+
+ var body: some View {
+ switch widgetFamily {
+ case .systemMedium:
+ MediumWidgetView(entry: entry)
+ default:
+ SmallWidgetView(entry: entry)
+ }
}
}
+// MARK: - Widget
+
struct Solsnu_Widget: Widget {
- let kind: String = "Solsnu_Widget_Small"
+ let kind = "Solsnu_Widget"
var body: some WidgetConfiguration {
StaticConfiguration(kind: kind, provider: Provider()) { entry in
- SmallWidgetView(entry: entry)
+ SolsticeWidgetView(entry: entry)
}
.contentMarginsDisabled()
.configurationDisplayName("Solstice Countdown")
.description("Days until next solstice or equinox")
- .supportedFamilies([.systemSmall,.systemMedium])
+ .supportedFamilies([.systemSmall, .systemMedium])
}
}
+// MARK: - Previews
+
#Preview(as: .systemSmall) {
Solsnu_Widget()
} timeline: {
- // Test with valid sunrise/sunset times (spring equinox, Oslo approx)
- 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))
-
- // Test with nil times (no location)
- SolvervEntry(def: SolvervDef(date: springDate, sunriseTime: nil, sunsetTime: nil))
-
- // Original equinox dates
- SolvervEntry(def: SolvervDef(utcString: "2026-03-20 14:46:00"))
- SolvervEntry(def: SolvervDef(utcString: "2026-06-21 08:25:00"))
- SolvervEntry(def: SolvervDef(utcString: "2026-09-23 00:06:00"))
- SolvervEntry(def: SolvervDef(utcString: "2026-12-21 20:50:00"))
+ let now = Date()
+ SolvervEntry(
+ date: now,
+ nextEvent: SolsticeData.shared.nextEvent(from: now),
+ currentEvent: SolsticeData.shared.currentEvent(from: now),
+ progressRatio: 0.4,
+ sunriseTime: Calendar.current.date(bySettingHour: 6, minute: 28, second: 0, of: now),
+ sunsetTime: Calendar.current.date(bySettingHour: 21, minute: 15, second: 0, of: now)
+ )
+ SolvervEntry.placeholder
}
#Preview(as: .systemMedium) {
Solsnu_Widget()
} timeline: {
- // Test medium widget with valid sunrise/sunset 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))
-
- // Test medium widget with nil times
- SolvervEntry(def: SolvervDef(date: springDate, sunriseTime: nil, sunsetTime: nil))
-
- // Original equinox dates
- SolvervEntry(def: SolvervDef(utcString: "2026-03-20 14:46:00"))
- SolvervEntry(def: SolvervDef(utcString: "2026-06-21 08:25:00"))
-}
-
-struct SolvervDef {
- let date: Date
- let bg: String
- let sunriseTime: Date?
- let sunsetTime: Date?
-
- // Cached formatter for performance
- private static let timeFormatter: DateFormatter = {
- let formatter = DateFormatter()
- formatter.dateFormat = "HH:mm"
- formatter.timeZone = TimeZone.current
- return formatter
- }()
-
- init(date: Date, sunriseTime: Date? = nil, sunsetTime: Date? = nil) {
- self.date = date
- self.sunriseTime = sunriseTime
- self.sunsetTime = sunsetTime
- self.bg = "smallbg"
- }
-
- 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")
- // Should probably guard this, but not now
- let date = formatter.date(from: utcString)!
- self.date = date
- self.sunriseTime = sunriseTime
- self.sunsetTime = sunsetTime
- self.bg = "smallbg"
- }
-
- var sunriseFormatted: String {
- guard let time = sunriseTime else { return "" }
- return Self.timeFormatter.string(from: time)
- }
-
- var sunsetFormatted: String {
- guard let time = sunsetTime else { return "" }
- return Self.timeFormatter.string(from: time)
- }
-}
-
-extension SolvervDef {
- var season: Season {
- guard let next = nextEvent else { return .winter }
- return next.season
- }
-
- var nextEvent: SolsticeEvent? {
- SolsticeData.shared.nextEvent(from: date)
- }
-
- func daysUntilNext() -> Int {
- guard let next = nextEvent else { return 0 }
- return next.daysUntil(from: date)
- }
-
- func progressRatio() -> Double {
- guard let progress = SolsticeData.shared.progressToNextEvent(from: date) 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, from: date)
- }
-
- static var preview: SolvervDef {
- SolvervDef(date: Date())
- }
+ let now = Date()
+ SolvervEntry(
+ date: now,
+ nextEvent: SolsticeData.shared.nextEvent(from: now),
+ currentEvent: SolsticeData.shared.currentEvent(from: now),
+ progressRatio: 0.4,
+ sunriseTime: Calendar.current.date(bySettingHour: 6, minute: 28, second: 0, of: now),
+ sunsetTime: Calendar.current.date(bySettingHour: 21, minute: 15, second: 0, of: now)
+ )
+ SolvervEntry.placeholder
}
diff --git a/Solsnu.Widget/Solsnu_WidgetBundle.swift b/Solsnu.Widget/Solsnu_WidgetBundle.swift
index 7bd3f1a..5a41904 100644
--- a/Solsnu.Widget/Solsnu_WidgetBundle.swift
+++ b/Solsnu.Widget/Solsnu_WidgetBundle.swift
@@ -12,26 +12,5 @@ import SwiftUI
struct Solsnu_WidgetBundle: WidgetBundle {
var body: some Widget {
Solsnu_Widget()
- Solsnu_WidgetMedium()
- }
-}
-
-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,.systemSmall])
}
}
diff --git a/Solsnu.Widget/Solsnu_WidgetControl.swift b/Solsnu.Widget/Solsnu_WidgetControl.swift
deleted file mode 100644
index 686a824..0000000
--- a/Solsnu.Widget/Solsnu_WidgetControl.swift
+++ /dev/null
@@ -1,55 +0,0 @@
-//
-// Solsnu_WidgetControl.swift
-// Solsnu.Widget
-//
-// Created by Ivar Løvlie on 15/12/2025.
-//
-
-import AppIntents
-import SwiftUI
-import WidgetKit
-
-struct Solsnu_WidgetControl: ControlWidget {
- var body: some ControlWidgetConfiguration {
- StaticControlConfiguration(
- kind: "ivarivarivar.Solverv.Solsnu.Widget",
- provider: Provider()
- ) { value in
- ControlWidgetToggle(
- "Start Timer",
- isOn: value,
- action: StartTimerIntent()
- ) { isRunning in
- Label(isRunning ? "On" : "Off", systemImage: "timer")
- }
- }
- .displayName("Timer")
- .description("A an example control that runs a timer.")
- }
-}
-
-extension Solsnu_WidgetControl {
- struct Provider: ControlValueProvider {
- var previewValue: Bool {
- false
- }
-
- func currentValue() async throws -> Bool {
- let isRunning = true // Check if the timer is running
- return isRunning
- }
- }
-}
-
-struct StartTimerIntent: SetValueIntent {
- static let title: LocalizedStringResource = "Start a timer"
-
- @Parameter(title: "Timer is running")
- var value: Bool
-
- func perform() async throws -> some IntentResult {
- // Start / stop the timer based on `value`.
- return .result()
- }
-}
- \ No newline at end of file
diff --git a/Solsnu.Widget/Views/MediumWidgetView.swift b/Solsnu.Widget/Views/MediumWidgetView.swift
index 69bbbe2..f6fdbd9 100644
--- a/Solsnu.Widget/Views/MediumWidgetView.swift
+++ b/Solsnu.Widget/Views/MediumWidgetView.swift
@@ -7,20 +7,30 @@ struct MediumWidgetView: View {
var body: some View {
HStack(spacing: 12) {
VStack(alignment: .leading, spacing: 8) {
- Text("Season Event")
- .font(.headline)
- .lineLimit(1)
+ if let next = entry.nextEvent {
+ Text(next.shortName)
+ .font(.headline)
+ .lineLimit(1)
- Text("\(entry.def.daysUntilNext()) days")
- .font(.system(.title3, design: .default).weight(.semibold))
+ Text("\(next.daysUntil(from: entry.date)) dager")
+ .font(.system(.title3, design: .default).weight(.semibold))
+ }
- ProgressView(value: Double(entry.def.progressRatio()))
+ ProgressView(value: entry.progressRatio)
- if !entry.def.sunriseFormatted.isEmpty && !entry.def.sunsetFormatted.isEmpty {
+ if let sunrise = entry.sunriseTime, let sunset = entry.sunsetTime {
HStack {
- Text("Sunrise: \(entry.def.sunriseFormatted)")
+ Label {
+ Text(sunrise, style: .time)
+ } icon: {
+ Image(systemName: "sunrise.fill")
+ }
Spacer()
- Text("Sunset: \(entry.def.sunsetFormatted)")
+ Label {
+ Text(sunset, style: .time)
+ } icon: {
+ Image(systemName: "sunset.fill")
+ }
}
.font(.system(size: 12, weight: .regular))
.foregroundStyle(Color(red: 0.152, green: 0.136, blue: 0.056))
@@ -32,6 +42,6 @@ struct MediumWidgetView: View {
Spacer()
}
.padding()
+ .containerBackground(.fill.tertiary, for: .widget)
}
}
-
diff --git a/Solsnu.Widget/Views/SmallWidgetView.swift b/Solsnu.Widget/Views/SmallWidgetView.swift
index 16ca102..b7dbf32 100644
--- a/Solsnu.Widget/Views/SmallWidgetView.swift
+++ b/Solsnu.Widget/Views/SmallWidgetView.swift
@@ -1,30 +1,34 @@
import SwiftUI
import WidgetKit
-import Combine
struct SmallWidgetView: View {
let entry: SolvervEntry
@Environment(\.widgetRenderingMode) var renderingMode
- @Environment(\.locale) var locale
-
+
var body: some View {
- ZStack {
- Image(entry.def.bg)
- VStack(spacing: 8) {
- Text("\(entry.def.daysUntilNext())")
- .font(.system(size: 26, weight: .bold, design: .serif))
- .position(x: 50,y: 50)
- .foregroundStyle(Color(red: 0.152, green: 0.136, blue: 0.056))
- .italic()
+ VStack(spacing: 8) {
+ Text("\(entry.nextEvent?.daysUntil(from: entry.date) ?? 0)")
+ .font(.system(size: 26, weight: .bold, design: .serif))
+ .italic()
+ .foregroundStyle(Color(red: 0.152, green: 0.136, blue: 0.056))
- HStack(spacing: 4) {
- Text("↑ \(entry.def.sunriseFormatted)")
- Text("↓ \(entry.def.sunsetFormatted)")
+ if let sunrise = entry.sunriseTime, let sunset = entry.sunsetTime {
+ HStack(spacing: 4) {
+ Label {
+ Text(sunrise, style: .time)
+ } icon: {
+ Image(systemName: "sunrise.fill")
+ }
+ Label {
+ Text(sunset, style: .time)
+ } icon: {
+ Image(systemName: "sunset.fill")
}
- .font(.system(size: 11, weight: .regular))
- .foregroundStyle(Color(red: 0.152, green: 0.136, blue: 0.056))
+ }
+ .font(.system(size: 11, weight: .regular))
+ .foregroundStyle(Color(red: 0.152, green: 0.136, blue: 0.056))
}
}
- .containerBackground(for: .widget, alignment: .center) { Color.clear }
+ .containerBackground(for: .widget, alignment: .center) { Color.blue }
}
}