Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions Resources/Info.plist
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,8 @@
<string>Sun Day needs your location to track UV exposure throughout the day</string>
<key>NSLocationWhenInUseUsageDescription</key>
<string>Sun Day needs your location to determine UV levels at your current position</string>
<key>NSSupportsLiveActivities</key>
<true/>
<key>NSUserNotificationCenterUsageDescription</key>
<string>Sun Day sends reminders for sunrise, sunset, and solar noon to help you track vitamin D</string>
<key>UILaunchStoryboardName</key>
Expand Down
22 changes: 11 additions & 11 deletions Resources/Sunday.entitlements
Original file line number Diff line number Diff line change
Expand Up @@ -2,15 +2,15 @@
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>com.apple.developer.healthkit</key>
<true/>
<key>com.apple.developer.healthkit.access</key>
<array>
<string>health-records</string>
</array>
<key>com.apple.security.application-groups</key>
<array>
<string>group.sunday.widget</string>
</array>
<key>com.apple.developer.healthkit</key>
<true/>
<key>com.apple.developer.healthkit.access</key>
<array>
<string>health-records</string>
</array>
<key>com.apple.security.application-groups</key>
<array>
<string>group.com.shashi.sunday</string>
</array>
</dict>
</plist>
</plist>
57 changes: 57 additions & 0 deletions Sources/Activities/LiveActivityManager.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
import ActivityKit
import Foundation

@MainActor
class LiveActivityManager {
static let shared = LiveActivityManager()
private var activity: Activity<SundayActivityAttributes>?

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<SundayActivityAttributes>.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
}
}
}
11 changes: 11 additions & 0 deletions Sources/Activities/SundayActivityAttributes.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
import ActivityKit
import Foundation

struct SundayActivityAttributes: ActivityAttributes {

public struct ContentState: Codable, Hashable {
var elapsedTime: TimeInterval
}

var startDate: Date
}
72 changes: 69 additions & 3 deletions Sources/Services/HealthManager.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)!

Expand All @@ -20,8 +21,8 @@ class HealthManager: ObservableObject {
return
}

let typesToWrite: Set<HKSampleType> = [vitaminDType]
let typesToRead: Set<HKObjectType> = [vitaminDType, fitzpatrickSkinType, dateOfBirthType]
let typesToWrite: Set<HKSampleType> = [vitaminDType, timeInDaylightType]
let typesToRead: Set<HKObjectType> = [vitaminDType, timeInDaylightType, fitzpatrickSkinType, dateOfBirthType]

healthStore.requestAuthorization(toShare: typesToWrite, read: typesToRead) { [weak self] success, error in
DispatchQueue.main.async {
Expand Down Expand Up @@ -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())
Expand Down Expand Up @@ -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()
Expand Down Expand Up @@ -211,4 +277,4 @@ class HealthManager: ObservableObject {

healthStore.execute(query)
}
}
}
28 changes: 23 additions & 5 deletions Sources/Services/VitaminDCalculator.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -199,7 +207,7 @@ class VitaminDCalculator: ObservableObject {
}
}

func startSession(uvIndex: Double) {
@MainActor func startSession(uvIndex: Double) {
guard isInSun else { return }

sessionStartTime = Date()
Expand All @@ -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
Expand All @@ -231,6 +245,8 @@ class VitaminDCalculator: ObservableObject {

// Update widget data
updateWidgetData()

LiveActivityManager.shared.stopActivity()
}

func updateUV(_ uvIndex: Double) {
Expand Down Expand Up @@ -284,25 +300,27 @@ 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
updateVitaminDRate(uvIndex: uvIndex)

// 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
sessionVitaminD += currentVitaminDRate * (elapsed / 3600.0)

// Update widget data
updateWidgetData()
LiveActivityManager.shared.updateActivity(elapsedTime: elapsed, isPaused: false)
}

func toggleSunExposure(uvIndex: Double) {
@MainActor func toggleSunExposure(uvIndex: Double) {
isInSun.toggle()

if isInSun {
Expand Down
Loading