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 +