diff --git a/Resources/Info.plist b/Resources/Info.plist
index 2c35291..08a8009 100644
--- a/Resources/Info.plist
+++ b/Resources/Info.plist
@@ -41,6 +41,8 @@
Sun Day needs your location to track UV exposure throughout the day
NSLocationWhenInUseUsageDescription
Sun Day needs your location to determine UV levels at your current position
+ NSSupportsLiveActivities
+
NSUserNotificationCenterUsageDescription
Sun Day sends reminders for sunrise, sunset, and solar noon to help you track vitamin D
UILaunchStoryboardName
diff --git a/Resources/Sunday.entitlements b/Resources/Sunday.entitlements
index 22a4ba5..72c2ed5 100644
--- a/Resources/Sunday.entitlements
+++ b/Resources/Sunday.entitlements
@@ -2,15 +2,15 @@
- com.apple.developer.healthkit
-
- com.apple.developer.healthkit.access
-
- health-records
-
- com.apple.security.application-groups
-
- group.sunday.widget
-
+ com.apple.developer.healthkit
+
+ com.apple.developer.healthkit.access
+
+ health-records
+
+ com.apple.security.application-groups
+
+ group.com.shashi.sunday
+
-
\ No newline at end of file
+
diff --git a/Sources/Activities/LiveActivityManager.swift b/Sources/Activities/LiveActivityManager.swift
new file mode 100644
index 0000000..d69e55d
--- /dev/null
+++ b/Sources/Activities/LiveActivityManager.swift
@@ -0,0 +1,57 @@
+import ActivityKit
+import Foundation
+
+@MainActor
+class LiveActivityManager {
+ static let shared = LiveActivityManager()
+ private var activity: Activity?
+
+ func startActivity(startDate: Date) {
+ guard ActivityAuthorizationInfo().areActivitiesEnabled else {
+ return
+ }
+
+ let initialState = SundayActivityAttributes.ContentState(
+ elapsedTime: 0,
+ )
+
+ let attributes = SundayActivityAttributes(startDate: startDate)
+
+ let content = ActivityContent(state: initialState, staleDate: nil)
+
+ do {
+ activity = try Activity.request(
+ attributes: attributes,
+ content: content,
+ pushType: nil
+ )
+ } catch {
+ print("Failed to start activity: \(error.localizedDescription)")
+ }
+ }
+
+ func updateActivity(elapsedTime: TimeInterval, isPaused: Bool) {
+ let newState = SundayActivityAttributes.ContentState(
+ elapsedTime: elapsedTime,
+ )
+
+ let updatedContent = ActivityContent(state: newState, staleDate: nil)
+
+ Task {
+ await activity?.update(updatedContent)
+ }
+ }
+
+ func stopActivity() {
+ let finalState = SundayActivityAttributes.ContentState(
+ elapsedTime: 0,
+ )
+
+ let finalContent = ActivityContent(state: finalState, staleDate: nil)
+
+ Task {
+ await activity?.end(finalContent, dismissalPolicy: .immediate)
+ activity = nil
+ }
+ }
+}
diff --git a/Sources/Activities/SundayActivityAttributes.swift b/Sources/Activities/SundayActivityAttributes.swift
new file mode 100644
index 0000000..91a904f
--- /dev/null
+++ b/Sources/Activities/SundayActivityAttributes.swift
@@ -0,0 +1,11 @@
+import ActivityKit
+import Foundation
+
+struct SundayActivityAttributes: ActivityAttributes {
+
+ public struct ContentState: Codable, Hashable {
+ var elapsedTime: TimeInterval
+ }
+
+ var startDate: Date
+}
diff --git a/Sources/Services/HealthManager.swift b/Sources/Services/HealthManager.swift
index 85578a8..831e820 100644
--- a/Sources/Services/HealthManager.swift
+++ b/Sources/Services/HealthManager.swift
@@ -7,6 +7,7 @@ class HealthManager: ObservableObject {
private let healthStore = HKHealthStore()
private let vitaminDType = HKQuantityType.quantityType(forIdentifier: .dietaryVitaminD)!
+ private let timeInDaylightType = HKQuantityType.quantityType(forIdentifier: .timeInDaylight)!
private let fitzpatrickSkinType = HKObjectType.characteristicType(forIdentifier: .fitzpatrickSkinType)!
private let dateOfBirthType = HKObjectType.characteristicType(forIdentifier: .dateOfBirth)!
@@ -20,8 +21,8 @@ class HealthManager: ObservableObject {
return
}
- let typesToWrite: Set = [vitaminDType]
- let typesToRead: Set = [vitaminDType, fitzpatrickSkinType, dateOfBirthType]
+ let typesToWrite: Set = [vitaminDType, timeInDaylightType]
+ let typesToRead: Set = [vitaminDType, timeInDaylightType, fitzpatrickSkinType, dateOfBirthType]
healthStore.requestAuthorization(toShare: typesToWrite, read: typesToRead) { [weak self] success, error in
DispatchQueue.main.async {
@@ -67,6 +68,37 @@ class HealthManager: ObservableObject {
}
}
+ func saveDaylightTime(seconds: Double, start: Date, end: Date) {
+ guard isAuthorized else {
+ requestAuthorization()
+ return
+ }
+
+ let quantity = HKQuantity(unit: .second(), doubleValue: seconds)
+ let sample = HKQuantitySample(
+ type: timeInDaylightType,
+ quantity: quantity,
+ start: start,
+ end: end,
+ metadata: [
+ HKMetadataKeyWasUserEntered: false,
+ "Source": "Sun Day App",
+ "Duration": seconds
+ ]
+ )
+
+ healthStore.save(sample) { [weak self] success, error in
+ DispatchQueue.main.async {
+ if success {
+ print("β
Saved time in daylight: \(seconds) seconds")
+ } else if let error = error {
+ self?.lastError = error.localizedDescription
+ print("β Error saving daylight time: \(error.localizedDescription)")
+ }
+ }
+ }
+ }
+
func getTodaysVitaminD(completion: @escaping (Double?) -> Void) {
let calendar = Calendar.current
let startOfDay = calendar.startOfDay(for: Date())
@@ -98,6 +130,40 @@ class HealthManager: ObservableObject {
healthStore.execute(query)
}
+ func getTodaysDaylight(completion: @escaping (Double?) -> Void) {
+ guard let timeInDaylightType = HKObjectType.quantityType(forIdentifier: .timeInDaylight) else {
+ completion(nil)
+ return
+ }
+
+ let calendar = Calendar.current
+ let startOfDay = calendar.startOfDay(for: Date())
+ let endOfDay = calendar.date(byAdding: .day, value: 1, to: startOfDay)!
+
+ let predicate = HKQuery.predicateForSamples(
+ withStart: startOfDay,
+ end: endOfDay,
+ options: .strictStartDate
+ )
+
+ let query = HKStatisticsQuery(
+ quantityType: timeInDaylightType,
+ quantitySamplePredicate: predicate,
+ options: .cumulativeSum
+ ) { _, result, error in
+ DispatchQueue.main.async {
+ if let sum = result?.sumQuantity() {
+ let seconds = sum.doubleValue(for: .second())
+ completion(seconds)
+ } else {
+ completion(nil)
+ }
+ }
+ }
+
+ healthStore.execute(query)
+ }
+
func getVitaminDHistory(days: Int, completion: @escaping ([Date: Double]) -> Void) {
let calendar = Calendar.current
let endDate = Date()
@@ -211,4 +277,4 @@ class HealthManager: ObservableObject {
healthStore.execute(query)
}
-}
\ No newline at end of file
+}
diff --git a/Sources/Services/VitaminDCalculator.swift b/Sources/Services/VitaminDCalculator.swift
index f3765ce..f79ae21 100644
--- a/Sources/Services/VitaminDCalculator.swift
+++ b/Sources/Services/VitaminDCalculator.swift
@@ -64,6 +64,14 @@ enum SkinType: Int, CaseIterable {
}
}
+extension VitaminDCalculator {
+ var elapsedTime: TimeInterval {
+ guard let start = sessionStartTime else { return 0 }
+ return Date().timeIntervalSince(start)
+ }
+}
+
+
class VitaminDCalculator: ObservableObject {
@Published var isInSun = false
@Published var clothingLevel: ClothingLevel = .light {
@@ -199,7 +207,7 @@ class VitaminDCalculator: ObservableObject {
}
}
- func startSession(uvIndex: Double) {
+ @MainActor func startSession(uvIndex: Double) {
guard isInSun else { return }
sessionStartTime = Date()
@@ -218,9 +226,15 @@ class VitaminDCalculator: ObservableObject {
}
updateVitaminDRate(uvIndex: uvIndex)
+ LiveActivityManager.shared.startActivity(startDate: sessionStartTime ?? Date())
}
- func stopSession() {
+ @MainActor func stopSession() {
+ // Store daylight in healthkit
+ if (sessionStartTime != nil) {
+ healthManager?.saveDaylightTime(seconds: elapsedTime, start: sessionStartTime!, end: Date())
+ }
+
timer?.invalidate()
timer = nil
sessionStartTime = nil
@@ -231,6 +245,8 @@ class VitaminDCalculator: ObservableObject {
// Update widget data
updateWidgetData()
+
+ LiveActivityManager.shared.stopActivity()
}
func updateUV(_ uvIndex: Double) {
@@ -284,7 +300,7 @@ class VitaminDCalculator: ObservableObject {
updateWidgetData()
}
- private func updateVitaminD(uvIndex: Double) {
+ @MainActor private func updateVitaminD(uvIndex: Double) {
guard isInSun else { return }
// Always recalculate rate with current UV to ensure accuracy
@@ -292,7 +308,8 @@ class VitaminDCalculator: ObservableObject {
// Calculate actual time elapsed since last update (should be ~1 second)
let now = Date()
- let elapsed = lastUpdateTime.map { now.timeIntervalSince($0) } ?? 1.0
+// let elapsed = lastUpdateTime.map { now.timeIntervalSince($0) } ?? 1.0
+ let elapsed = elapsedTime
lastUpdateTime = now
// Add vitamin D based on actual elapsed time
@@ -300,9 +317,10 @@ class VitaminDCalculator: ObservableObject {
// Update widget data
updateWidgetData()
+ LiveActivityManager.shared.updateActivity(elapsedTime: elapsed, isPaused: false)
}
- func toggleSunExposure(uvIndex: Double) {
+ @MainActor func toggleSunExposure(uvIndex: Double) {
isInSun.toggle()
if isInSun {
diff --git a/Sources/Views/ContentView.swift b/Sources/Views/ContentView.swift
index 26c779d..ac2faec 100644
--- a/Sources/Views/ContentView.swift
+++ b/Sources/Views/ContentView.swift
@@ -17,6 +17,7 @@ struct ContentView: View {
@State private var showClothingPicker = false
@State private var showSkinTypePicker = false
@State private var todaysTotal: Double = 0
+ @State private var todaysDaylight: Double = 0
@State private var currentGradientColors: [Color] = []
@State private var showInfoSheet = false
@State private var lastUVUpdate: Date = UserDefaults.standard.object(forKey: "lastUVUpdate") as? Date ?? Date()
@@ -133,6 +134,7 @@ struct ContentView: View {
timerCancellable = timer.autoconnect().sink { _ in
updateData()
loadTodaysTotal()
+ loadTodaysDaylight()
// Only update gradient if colors actually changed
let newColors = gradientColors
if newColors != currentGradientColors {
@@ -142,6 +144,7 @@ struct ContentView: View {
// Also update data immediately when returning to foreground
updateData()
loadTodaysTotal()
+ loadTodaysDaylight()
// Update gradient immediately when returning to foreground
let newColors = gradientColors
if newColors != currentGradientColors {
@@ -505,7 +508,7 @@ struct ContentView: View {
.frame(height: 12)
Text(formatVitaminDNumber(vitaminDCalculator.currentVitaminDRate / 60.0))
- .font(.system(size: 26, weight: .bold))
+ .font(.system(size: 22, weight: .bold))
.foregroundColor(.white)
.monospacedDigit()
.frame(minWidth: 80)
@@ -527,7 +530,7 @@ struct ContentView: View {
HStack(spacing: 4) {
Text(formatVitaminDNumber(vitaminDCalculator.sessionVitaminD))
- .font(.system(size: 26, weight: .bold))
+ .font(.system(size: 22, weight: .bold))
.foregroundColor(.white)
.monospacedDigit()
.frame(minWidth: 80, alignment: .trailing)
@@ -562,17 +565,35 @@ struct ContentView: View {
.tracking(1.2)
.frame(height: 12)
- Text(formatTodaysTotal(todaysTotal + vitaminDCalculator.sessionVitaminD))
- .font(.system(size: 26, weight: .bold))
- .foregroundColor(.white)
- .monospacedDigit()
- .frame(minWidth: 80)
- .frame(height: 34)
-
- Text("IU total")
- .font(.system(size: 12))
- .foregroundColor(.white.opacity(0.6))
- .frame(height: 16)
+ HStack(spacing: 4) {
+ VStack(spacing: 8) {
+ Text(formatTodaysTotal(todaysTotal + vitaminDCalculator.sessionVitaminD))
+ .font(.system(size: 22, weight: .bold))
+ .foregroundColor(.white)
+ .monospacedDigit()
+ .frame(minWidth: 80)
+ .frame(height: 34)
+
+ Text("IU total")
+ .font(.system(size: 12))
+ .foregroundColor(.white.opacity(0.6))
+ .frame(height: 16)
+ }
+
+ VStack(spacing: 8) {
+ Text(formatTodaysDaylight(todaysDaylight + vitaminDCalculator.elapsedTime))
+ .font(.system(size: 22, weight: .bold))
+ .foregroundColor(.white)
+ .monospacedDigit()
+ .frame(minWidth: 80)
+ .frame(height: 34)
+
+ Text("Daylight")
+ .font(.system(size: 12))
+ .foregroundColor(.white.opacity(0.6))
+ .frame(height: 16)
+ }
+ }
}
.frame(minWidth: 100)
}
@@ -599,6 +620,7 @@ struct ContentView: View {
locationManager.requestPermission()
healthManager.requestAuthorization()
loadTodaysTotal()
+ loadTodaysDaylight()
currentGradientColors = gradientColors
// Connect services - MUST set modelContext before any UV data fetching
@@ -647,13 +669,16 @@ struct ContentView: View {
if !vitaminDCalculator.isInSun && vitaminDCalculator.sessionVitaminD > 0 {
let sessionAmount = vitaminDCalculator.sessionVitaminD
healthManager.saveVitaminD(amount: sessionAmount)
+
// Add the session amount to today's total immediately
todaysTotal += sessionAmount
+ todaysDaylight += vitaminDCalculator.elapsedTime
// Reset the session vitamin D after saving
vitaminDCalculator.sessionVitaminD = 0.0
// Then reload from HealthKit to ensure accuracy
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) {
loadTodaysTotal()
+ loadTodaysDaylight()
}
}
}
@@ -664,6 +689,12 @@ struct ContentView: View {
}
}
+ private func loadTodaysDaylight() {
+ healthManager.getTodaysDaylight { total in
+ todaysDaylight = total ?? 0
+ }
+ }
+
private func formatVitaminD(_ value: Double) -> String {
if value < 1 {
return String(format: "%.2f IU", value)
@@ -707,6 +738,21 @@ struct ContentView: View {
}
}
+ func formatTodaysDaylight(_ seconds: TimeInterval) -> String {
+ let totalMinutes = Int(seconds / 60)
+ let hours = totalMinutes / 60
+ let minutes = totalMinutes % 60
+
+ switch (hours, minutes) {
+ case (0, let m):
+ return "\(m)m"
+ case (let h, 0):
+ return "\(h)h"
+ default:
+ return "\(hours)h \(minutes)m"
+ }
+ }
+
private func sessionDurationString(from startTime: Date) -> String {
let duration = Date().timeIntervalSince(startTime)
let minutes = Int(duration / 60)
diff --git a/SundayLiveActivity/AppIntent.swift b/SundayLiveActivity/AppIntent.swift
new file mode 100644
index 0000000..1ebaea3
--- /dev/null
+++ b/SundayLiveActivity/AppIntent.swift
@@ -0,0 +1,18 @@
+//
+// AppIntent.swift
+// SundayLiveActivity
+//
+// Created by Shashi on 18/07/25.
+//
+
+import WidgetKit
+import AppIntents
+
+struct ConfigurationAppIntent: WidgetConfigurationIntent {
+ static var title: LocalizedStringResource { "Configuration" }
+ static var description: IntentDescription { "This is an example widget." }
+
+ // An example configurable parameter.
+ @Parameter(title: "Favorite Emoji", default: "π")
+ var favoriteEmoji: String
+}
diff --git a/SundayLiveActivity/Assets.xcassets/AccentColor.colorset/Contents.json b/SundayLiveActivity/Assets.xcassets/AccentColor.colorset/Contents.json
new file mode 100644
index 0000000..eb87897
--- /dev/null
+++ b/SundayLiveActivity/Assets.xcassets/AccentColor.colorset/Contents.json
@@ -0,0 +1,11 @@
+{
+ "colors" : [
+ {
+ "idiom" : "universal"
+ }
+ ],
+ "info" : {
+ "author" : "xcode",
+ "version" : 1
+ }
+}
diff --git a/SundayLiveActivity/Assets.xcassets/AppIcon.appiconset/Contents.json b/SundayLiveActivity/Assets.xcassets/AppIcon.appiconset/Contents.json
new file mode 100644
index 0000000..2305880
--- /dev/null
+++ b/SundayLiveActivity/Assets.xcassets/AppIcon.appiconset/Contents.json
@@ -0,0 +1,35 @@
+{
+ "images" : [
+ {
+ "idiom" : "universal",
+ "platform" : "ios",
+ "size" : "1024x1024"
+ },
+ {
+ "appearances" : [
+ {
+ "appearance" : "luminosity",
+ "value" : "dark"
+ }
+ ],
+ "idiom" : "universal",
+ "platform" : "ios",
+ "size" : "1024x1024"
+ },
+ {
+ "appearances" : [
+ {
+ "appearance" : "luminosity",
+ "value" : "tinted"
+ }
+ ],
+ "idiom" : "universal",
+ "platform" : "ios",
+ "size" : "1024x1024"
+ }
+ ],
+ "info" : {
+ "author" : "xcode",
+ "version" : 1
+ }
+}
diff --git a/SundayLiveActivity/Assets.xcassets/Contents.json b/SundayLiveActivity/Assets.xcassets/Contents.json
new file mode 100644
index 0000000..73c0059
--- /dev/null
+++ b/SundayLiveActivity/Assets.xcassets/Contents.json
@@ -0,0 +1,6 @@
+{
+ "info" : {
+ "author" : "xcode",
+ "version" : 1
+ }
+}
diff --git a/SundayLiveActivity/Assets.xcassets/WidgetBackground.colorset/Contents.json b/SundayLiveActivity/Assets.xcassets/WidgetBackground.colorset/Contents.json
new file mode 100644
index 0000000..eb87897
--- /dev/null
+++ b/SundayLiveActivity/Assets.xcassets/WidgetBackground.colorset/Contents.json
@@ -0,0 +1,11 @@
+{
+ "colors" : [
+ {
+ "idiom" : "universal"
+ }
+ ],
+ "info" : {
+ "author" : "xcode",
+ "version" : 1
+ }
+}
diff --git a/SundayLiveActivity/Info.plist b/SundayLiveActivity/Info.plist
new file mode 100644
index 0000000..0f118fb
--- /dev/null
+++ b/SundayLiveActivity/Info.plist
@@ -0,0 +1,11 @@
+
+
+
+
+ NSExtension
+
+ NSExtensionPointIdentifier
+ com.apple.widgetkit-extension
+
+
+
diff --git a/SundayLiveActivity/SundayLiveActivity.swift b/SundayLiveActivity/SundayLiveActivity.swift
new file mode 100644
index 0000000..5123ce3
--- /dev/null
+++ b/SundayLiveActivity/SundayLiveActivity.swift
@@ -0,0 +1,97 @@
+import ActivityKit
+import SwiftUI
+import WidgetKit
+
+struct SundayLiveActivity: Widget {
+ var body: some WidgetConfiguration {
+ ActivityConfiguration(for: SundayActivityAttributes.self) { context in
+ VStack(alignment: .leading, spacing: 8) {
+ HStack {
+ Image(systemName: "sun.max.fill")
+ .foregroundColor(Color.yellow)
+ .font(.system(size: 28))
+
+ Spacer()
+
+ VStack(alignment: .trailing, spacing: 4) {
+ Text("In Sun")
+ .font(.title2)
+ .foregroundColor(Color.yellow)
+
+ Text(elapsedTimeString(from: context.state.elapsedTime))
+ .font(.caption)
+ .monospacedDigit()
+ .bold()
+ .foregroundColor(Color.yellow)
+ }
+ }
+ }
+ .padding()
+ .background(Color.black)
+ .activityBackgroundTint(.black)
+ .activitySystemActionForegroundColor(Color.yellow)
+
+ } dynamicIsland: { context in
+ DynamicIsland {
+ // Expanded (long-press or music-style view)
+ DynamicIslandExpandedRegion(.center) {
+ HStack {
+ // Sun icon
+ Image(systemName: "sun.max.fill")
+ .foregroundColor(Color.yellow)
+ .font(.system(size: 24))
+
+ VStack(alignment: .leading, spacing: 4) {
+ // Status
+ Text("In Sun")
+ .font(.headline)
+ .foregroundColor(Color.yellow)
+
+ // Time
+ Text(elapsedTimeString(from: context.state.elapsedTime))
+ .font(.subheadline)
+ .monospacedDigit()
+ .foregroundColor(Color.yellow.opacity(0.3))
+ }
+ }
+ .padding(.horizontal)
+ .frame(maxWidth: .infinity, alignment: .leading)
+ .background(Color.black)
+ }
+ }
+
+ // Compact leading (left pill)
+ compactLeading: {
+ Image(systemName: "sun.max.fill")
+ .foregroundColor(Color.yellow)
+ }
+
+ // Compact trailing (right pill)
+ compactTrailing: {
+ Text(elapsedShort(from: context.state.elapsedTime))
+ .foregroundColor(Color.yellow)
+ .monospacedDigit()
+ }
+
+ // Minimal (dot state)
+ minimal: {
+ Image(systemName: "sun.max.fill")
+ .foregroundColor(Color.yellow)
+ }
+ }
+
+ }
+
+ func elapsedTimeString(from interval: TimeInterval) -> String {
+ let formatter = DateComponentsFormatter()
+ formatter.unitsStyle = .positional
+ formatter.allowedUnits = [.hour, .minute, .second]
+ formatter.zeroFormattingBehavior = [.pad]
+ return formatter.string(from: interval) ?? "00:00"
+ }
+
+ func elapsedShort(from interval: TimeInterval) -> String {
+ let minutes = Int(interval) / 60
+ return "\(minutes)m"
+ }
+}
diff --git a/SundayLiveActivity/SundayLiveActivityBundle.swift b/SundayLiveActivity/SundayLiveActivityBundle.swift
new file mode 100644
index 0000000..4f1363c
--- /dev/null
+++ b/SundayLiveActivity/SundayLiveActivityBundle.swift
@@ -0,0 +1,18 @@
+//
+// SundayLiveActivityBundle.swift
+// SundayLiveActivity
+//
+// Created by Shashi on 18/07/25.
+//
+
+import WidgetKit
+import SwiftUI
+
+@main
+struct SundayLiveActivityBundle: WidgetBundle {
+ var body: some Widget {
+ SundayLiveActivity()
+ SundayLiveActivityControl()
+ SundayLiveActivityLiveActivity()
+ }
+}
diff --git a/SundayLiveActivity/SundayLiveActivityControl.swift b/SundayLiveActivity/SundayLiveActivityControl.swift
new file mode 100644
index 0000000..12dbd7e
--- /dev/null
+++ b/SundayLiveActivity/SundayLiveActivityControl.swift
@@ -0,0 +1,77 @@
+//
+// SundayLiveActivityControl.swift
+// SundayLiveActivity
+//
+// Created by Shashi on 18/07/25.
+//
+
+import AppIntents
+import SwiftUI
+import WidgetKit
+
+struct SundayLiveActivityControl: ControlWidget {
+ static let kind: String = "com.shashi.sunday.SundayLiveActivity"
+
+ var body: some ControlWidgetConfiguration {
+ AppIntentControlConfiguration(
+ kind: Self.kind,
+ provider: Provider()
+ ) { value in
+ ControlWidgetToggle(
+ "Start Timer",
+ isOn: value.isRunning,
+ action: StartTimerIntent(value.name)
+ ) { isRunning in
+ Label(isRunning ? "On" : "Off", systemImage: "timer")
+ }
+ }
+ .displayName("Timer")
+ .description("A an example control that runs a timer.")
+ }
+}
+
+extension SundayLiveActivityControl {
+ struct Value {
+ var isRunning: Bool
+ var name: String
+ }
+
+ struct Provider: AppIntentControlValueProvider {
+ func previewValue(configuration: TimerConfiguration) -> Value {
+ SundayLiveActivityControl.Value(isRunning: false, name: configuration.timerName)
+ }
+
+ func currentValue(configuration: TimerConfiguration) async throws -> Value {
+ let isRunning = true // Check if the timer is running
+ return SundayLiveActivityControl.Value(isRunning: isRunning, name: configuration.timerName)
+ }
+ }
+}
+
+struct TimerConfiguration: ControlConfigurationIntent {
+ static let title: LocalizedStringResource = "Timer Name Configuration"
+
+ @Parameter(title: "Timer Name", default: "Timer")
+ var timerName: String
+}
+
+struct StartTimerIntent: SetValueIntent {
+ static let title: LocalizedStringResource = "Start a timer"
+
+ @Parameter(title: "Timer Name")
+ var name: String
+
+ @Parameter(title: "Timer is running")
+ var value: Bool
+
+ init() {}
+
+ init(_ name: String) {
+ self.name = name
+ }
+
+ func perform() async throws -> some IntentResult {
+ // Start the timerβ¦
+ return .result()
+ }
+}
diff --git a/SundayLiveActivity/SundayLiveActivityLiveActivity.swift b/SundayLiveActivity/SundayLiveActivityLiveActivity.swift
new file mode 100644
index 0000000..4c4c01e
--- /dev/null
+++ b/SundayLiveActivity/SundayLiveActivityLiveActivity.swift
@@ -0,0 +1,80 @@
+//
+// SundayLiveActivityLiveActivity.swift
+// SundayLiveActivity
+//
+// Created by Shashi on 18/07/25.
+//
+
+import ActivityKit
+import WidgetKit
+import SwiftUI
+
+struct SundayLiveActivityAttributes: ActivityAttributes {
+ public struct ContentState: Codable, Hashable {
+ // Dynamic stateful properties about your activity go here!
+ var emoji: String
+ }
+
+ // Fixed non-changing properties about your activity go here!
+ var name: String
+}
+
+struct SundayLiveActivityLiveActivity: Widget {
+ var body: some WidgetConfiguration {
+ ActivityConfiguration(for: SundayLiveActivityAttributes.self) { context in
+ // Lock screen/banner UI goes here
+ VStack {
+ Text("Hello \(context.state.emoji)")
+ }
+ .activityBackgroundTint(Color.cyan)
+ .activitySystemActionForegroundColor(Color.black)
+
+ } dynamicIsland: { context in
+ DynamicIsland {
+ // Expanded UI goes here. Compose the expanded UI through
+ // various regions, like leading/trailing/center/bottom
+ DynamicIslandExpandedRegion(.leading) {
+ Text("Leading")
+ }
+ DynamicIslandExpandedRegion(.trailing) {
+ Text("Trailing")
+ }
+ DynamicIslandExpandedRegion(.bottom) {
+ Text("Bottom \(context.state.emoji)")
+ // more content
+ }
+ } compactLeading: {
+ Text("L")
+ } compactTrailing: {
+ Text("T \(context.state.emoji)")
+ } minimal: {
+ Text(context.state.emoji)
+ }
+ .widgetURL(URL(string: "http://www.apple.com"))
+ .keylineTint(Color.red)
+ }
+ }
+}
+
+extension SundayLiveActivityAttributes {
+ fileprivate static var preview: SundayLiveActivityAttributes {
+ SundayLiveActivityAttributes(name: "World")
+ }
+}
+
+extension SundayLiveActivityAttributes.ContentState {
+ fileprivate static var smiley: SundayLiveActivityAttributes.ContentState {
+ SundayLiveActivityAttributes.ContentState(emoji: "π")
+ }
+
+ fileprivate static var starEyes: SundayLiveActivityAttributes.ContentState {
+ SundayLiveActivityAttributes.ContentState(emoji: "π€©")
+ }
+}
+
+#Preview("Notification", as: .content, using: SundayLiveActivityAttributes.preview) {
+ SundayLiveActivityLiveActivity()
+} contentStates: {
+ SundayLiveActivityAttributes.ContentState.smiley
+ SundayLiveActivityAttributes.ContentState.starEyes
+}
diff --git a/SundayWidget/SundayWidget.entitlements b/SundayWidget/SundayWidget.entitlements
index 1abfdac..9044d87 100644
--- a/SundayWidget/SundayWidget.entitlements
+++ b/SundayWidget/SundayWidget.entitlements
@@ -2,9 +2,9 @@
- com.apple.security.application-groups
-
- group.sunday.widget
-
+ com.apple.security.application-groups
+
+ group.com.shashi.sunday
+
-
\ No newline at end of file
+
diff --git a/SundayWidget/SundayWidget.swift b/SundayWidget/SundayWidget.swift
index 152c467..bf3af9c 100644
--- a/SundayWidget/SundayWidget.swift
+++ b/SundayWidget/SundayWidget.swift
@@ -13,11 +13,11 @@ private let sharedNumberFormatter: NumberFormatter = {
struct Provider: IntentTimelineProvider {
func placeholder(in context: Context) -> SimpleEntry {
- SimpleEntry(date: Date(), uvIndex: 5.0, todaysTotal: 2500, isTracking: false, vitaminDRate: 350, locationName: "Rome", moonPhaseName: "Full Moon", altitude: 100, uvMultiplier: 1.01, cloudCover: 20.0, configuration: ConfigurationIntent())
+ SimpleEntry(date: Date(), uvIndex: 5.0, todaysTotal: 2500, todaysDaylight: 120, isTracking: false, vitaminDRate: 350, locationName: "Rome", moonPhaseName: "Full Moon", altitude: 100, uvMultiplier: 1.01, cloudCover: 20.0, configuration: ConfigurationIntent())
}
func getSnapshot(for configuration: ConfigurationIntent, in context: Context, completion: @escaping (SimpleEntry) -> ()) {
- let entry = SimpleEntry(date: Date(), uvIndex: 5.0, todaysTotal: 2500, isTracking: false, vitaminDRate: 350, locationName: "Rome", moonPhaseName: "Full Moon", altitude: 100, uvMultiplier: 1.01, cloudCover: 20.0, configuration: configuration)
+ let entry = SimpleEntry(date: Date(), uvIndex: 5.0, todaysTotal: 2500, todaysDaylight: 120, isTracking: false, vitaminDRate: 350, locationName: "Rome", moonPhaseName: "Full Moon", altitude: 100, uvMultiplier: 1.01, cloudCover: 20.0, configuration: configuration)
completion(entry)
}
@@ -28,6 +28,7 @@ struct Provider: IntentTimelineProvider {
let sharedDefaults = UserDefaults(suiteName: "group.sunday.widget")
let uvIndex = sharedDefaults?.double(forKey: "currentUV") ?? 0.0
let todaysTotal = sharedDefaults?.double(forKey: "todaysTotal") ?? 0.0
+ let todaysDaylight = sharedDefaults?.double(forKey: "todaysDaylight") ?? 0.0
let isTracking = sharedDefaults?.bool(forKey: "isTracking") ?? false
let vitaminDRate = sharedDefaults?.double(forKey: "vitaminDRate") ?? 0.0
let locationName = sharedDefaults?.string(forKey: "locationName") ?? ""
@@ -66,7 +67,7 @@ struct Provider: IntentTimelineProvider {
// Create entries
for entryDate in entryDates {
- let entry = SimpleEntry(date: entryDate, uvIndex: uvIndex, todaysTotal: todaysTotal, isTracking: isTracking, vitaminDRate: vitaminDRate, locationName: locationName, moonPhaseName: moonPhaseName, altitude: altitude, uvMultiplier: uvMultiplier, cloudCover: cloudCover, configuration: configuration)
+ let entry = SimpleEntry(date: entryDate, uvIndex: uvIndex, todaysTotal: todaysTotal, todaysDaylight: todaysDaylight, isTracking: isTracking, vitaminDRate: vitaminDRate, locationName: locationName, moonPhaseName: moonPhaseName, altitude: altitude, uvMultiplier: uvMultiplier, cloudCover: cloudCover, configuration: configuration)
entries.append(entry)
}
@@ -79,6 +80,7 @@ struct SimpleEntry: TimelineEntry {
let date: Date
let uvIndex: Double
let todaysTotal: Double
+ let todaysDaylight: Double
let isTracking: Bool
let vitaminDRate: Double
let locationName: String
@@ -127,12 +129,24 @@ struct SmallWidgetView: View {
Text("TODAY")
.font(.system(size: 10, weight: .medium))
.foregroundColor(.white.opacity(0.7))
- Text(formatNumber(entry.todaysTotal))
- .font(.system(size: 16, weight: .semibold))
- .foregroundColor(.white)
- Text("IU")
- .font(.system(size: 10))
- .foregroundColor(.white.opacity(0.7))
+ HStack(spacing: 15) {
+ VStack(spacing: 0) {
+ Text(formatNumber(entry.todaysTotal))
+ .font(.system(size: 16, weight: .semibold))
+ .foregroundColor(.white)
+ Text("IU")
+ .font(.system(size: 10))
+ .foregroundColor(.white.opacity(0.7))
+ }
+ VStack(spacing: 0) {
+ Text(formatDaylightDuration(entry.todaysDaylight))
+ .font(.system(size: 16, weight: .semibold))
+ .foregroundColor(.white)
+ Text("Daylight")
+ .font(.system(size: 10))
+ .foregroundColor(.white.opacity(0.7))
+ }
+ }
}
// Tracking indicator
@@ -201,6 +215,23 @@ struct SmallWidgetView: View {
return String(format: "%.0fK", value / 1000)
}
}
+
+ func formatDaylightDuration(_ interval: TimeInterval) -> String {
+ let totalSeconds = Int(interval)
+ let hours = totalSeconds / 3600
+ let minutes = (totalSeconds % 3600) / 60
+ let seconds = totalSeconds % 60
+
+ if hours > 0 && minutes > 0 {
+ return "\(hours)h \(minutes)m"
+ } else if hours > 0 {
+ return "\(hours)h"
+ } else if minutes > 0 {
+ return "\(minutes)m"
+ } else {
+ return "\(seconds)s"
+ }
+ }
}
struct MediumWidgetView: View {
@@ -230,13 +261,23 @@ struct MediumWidgetView: View {
Text("TODAY")
.font(.system(size: 10, weight: .medium))
.foregroundColor(.white.opacity(0.7))
- HStack(spacing: 2) {
- Text(formatNumber(entry.todaysTotal))
- .font(.system(size: 18, weight: .semibold))
- .foregroundColor(.white)
- Text("IU")
- .font(.system(size: 11))
- .foregroundColor(.white.opacity(0.8))
+ HStack(spacing: 15) {
+ VStack(spacing: 0) {
+ Text(formatNumber(entry.todaysTotal))
+ .font(.system(size: 18, weight: .semibold))
+ .foregroundColor(.white)
+ Text("IU")
+ .font(.system(size: 11))
+ .foregroundColor(.white.opacity(0.8))
+ }
+ VStack(spacing: 0) {
+ Text(formatDaylightDuration(entry.todaysDaylight))
+ .font(.system(size: 18, weight: .semibold))
+ .foregroundColor(.white)
+ Text("Daylight")
+ .font(.system(size: 11))
+ .foregroundColor(.white.opacity(0.8))
+ }
}
}
@@ -382,6 +423,23 @@ struct MediumWidgetView: View {
return String(format: "%.0fK", value / 1000)
}
}
+
+ func formatDaylightDuration(_ interval: TimeInterval) -> String {
+ let totalSeconds = Int(interval)
+ let hours = totalSeconds / 3600
+ let minutes = (totalSeconds % 3600) / 60
+ let seconds = totalSeconds % 60
+
+ if hours > 0 && minutes > 0 {
+ return "\(hours)h \(minutes)m"
+ } else if hours > 0 {
+ return "\(hours)h"
+ } else if minutes > 0 {
+ return "\(minutes)m"
+ } else {
+ return "\(seconds)s"
+ }
+ }
}
extension Color {
@@ -471,7 +529,7 @@ struct SundayWidget: Widget {
struct SundayWidget_Previews: PreviewProvider {
static var previews: some View {
- SundayWidgetEntryView(entry: SimpleEntry(date: Date(), uvIndex: 5.0, todaysTotal: 2500, isTracking: false, vitaminDRate: 350, locationName: "Rome", moonPhaseName: "Full Moon", altitude: 100, uvMultiplier: 1.01, cloudCover: 20.0, configuration: ConfigurationIntent()))
+ SundayWidgetEntryView(entry: SimpleEntry(date: Date(), uvIndex: 5.0, todaysTotal: 2500, todaysDaylight: 120, isTracking: false, vitaminDRate: 350, locationName: "Rome", moonPhaseName: "Full Moon", altitude: 100, uvMultiplier: 1.01, cloudCover: 20.0, configuration: ConfigurationIntent()))
.previewContext(WidgetPreviewContext(family: .systemSmall))
}
}
diff --git a/project.yml b/project.yml
index cf21bda..e55a4c5 100644
--- a/project.yml
+++ b/project.yml
@@ -61,6 +61,7 @@ targets:
- group.sunday.widget
dependencies:
- target: SundayWidget
+ - target: SundayLiveActivity
SundayWidget:
type: app-extension
@@ -87,3 +88,31 @@ targets:
enabled: true
groups:
- group.sunday.widget
+
+ SundayLiveActivity:
+ type: app-extension
+ platform: iOS
+ sources:
+ - SundayLiveActivity
+ resources:
+ - SundayLiveActivity/Assets.xcassets
+ info:
+ path: SundayLiveActivity/Info.plist
+ properties:
+ CFBundleDisplayName: Sun Day Activity
+ NSExtension:
+ NSExtensionPointIdentifier: com.apple.dynamic-island-content
+ NSExtensionPrincipalClass: $(PRODUCT_MODULE_NAME).LiveActivity
+ settings:
+ base:
+ PRODUCT_BUNDLE_IDENTIFIER: it.sunday.app.liveactivity
+ INFOPLIST_FILE: SundayLiveActivity/Info.plist
+ SKIP_INSTALL: true
+ TARGETED_DEVICE_FAMILY: "1"
+ CODE_SIGN_ENTITLEMENTS: SundayLiveActivity/SundayLiveActivity.entitlements
+ capabilities:
+ - com.apple.ApplicationGroups:
+ enabled: true
+ groups:
+ - group.sunday.widget
+