diff --git a/Resources/Info.plist b/Resources/Info.plist
index f762482..2c35291 100644
--- a/Resources/Info.plist
+++ b/Resources/Info.plist
@@ -17,7 +17,7 @@
CFBundlePackageType
APPL
CFBundleShortVersionString
- 1.1.2
+ 1.0
CFBundleURLTypes
@@ -53,18 +53,5 @@
UIUserInterfaceStyle
Light
- NSAppTransportSecurity
-
- NSExceptionDomains
-
- api.farmsense.net
-
- NSExceptionAllowsInsecureHTTPLoads
-
- NSIncludesSubdomains
-
-
-
-
diff --git a/Sources/Views/SessionCompletionSheet.swift b/Sources/Views/SessionCompletionSheet.swift
index 0aeecbf..f5d4e56 100644
--- a/Sources/Views/SessionCompletionSheet.swift
+++ b/Sources/Views/SessionCompletionSheet.swift
@@ -7,58 +7,72 @@ struct SessionCompletionSheet: View {
@EnvironmentObject var vitaminDCalculator: VitaminDCalculator
@EnvironmentObject var healthManager: HealthManager
@Environment(\.modelContext) private var modelContext
-
+ @AppStorage("usesMCG") private var usesMCG: Bool = false
+
let sessionStartTime: Date
let sessionAmount: Double
let onSave: () -> Void
let onCancel: () -> Void
-
+
+ @State private var selectedStartTime: Date
@State private var selectedEndTime: Date
-
- init(sessionStartTime: Date, sessionAmount: Double, onSave: @escaping () -> Void, onCancel: @escaping () -> Void) {
+ /// Live amount — updated 0.5 s after either time wheel stops moving
+ @State private var currentAmount: Double
+ /// Debounce handle for recalculation
+ @State private var recalcTask: Task?
+ /// Duration at the moment the sheet opened — used as the denominator for scaling
+ private let originalDuration: TimeInterval
+
+ init(sessionStartTime: Date, sessionAmount: Double,
+ onSave: @escaping () -> Void, onCancel: @escaping () -> Void) {
self.sessionStartTime = sessionStartTime
self.sessionAmount = sessionAmount
self.onSave = onSave
self.onCancel = onCancel
- self._selectedEndTime = State(initialValue: Date())
+ let now = Date()
+ self._selectedStartTime = State(initialValue: sessionStartTime)
+ self._selectedEndTime = State(initialValue: now)
+ self._currentAmount = State(initialValue: sessionAmount)
+ self.originalDuration = now.timeIntervalSince(sessionStartTime)
}
-
+
+ // MARK: Computed helpers
+
private var sessionDuration: TimeInterval {
- selectedEndTime.timeIntervalSince(sessionStartTime)
+ max(0, selectedEndTime.timeIntervalSince(selectedStartTime))
}
-
+
private var formattedDuration: String {
let minutes = Int(sessionDuration / 60)
if minutes < 60 {
return "\(minutes) min"
} else {
let hours = minutes / 60
- let remainingMinutes = minutes % 60
- if remainingMinutes == 0 {
- return "\(hours) hr"
- } else {
- return "\(hours) hr \(remainingMinutes) min"
- }
+ let rem = minutes % 60
+ return rem == 0 ? "\(hours) hr" : "\(hours) hr \(rem) min"
}
}
-
+
private var formattedAmount: String {
- if sessionAmount < 1000 {
- return "\(Int(sessionAmount)) IU"
- } else if sessionAmount < 100000 {
- let formatter = NumberFormatter()
- formatter.numberStyle = .decimal
- formatter.maximumFractionDigits = 0
- return "\(formatter.string(from: NSNumber(value: sessionAmount)) ?? "\(Int(sessionAmount))") IU"
- } else {
- return String(format: "%.0fK IU", sessionAmount / 1000)
+ let value = usesMCG ? currentAmount / 40.0 : currentAmount
+ let unit = usesMCG ? "mcg" : "IU"
+ if value == 0 { return "0 \(unit)" }
+ if value < 1 { return String(format: "%.1f \(unit)", value) }
+ if value < 1000 { return "\(Int(value)) \(unit)" }
+ if value < 100_000 {
+ let f = NumberFormatter()
+ f.numberStyle = .decimal
+ f.maximumFractionDigits = 0
+ return "\(f.string(from: NSNumber(value: value)) ?? "\(Int(value))") \(unit)"
}
+ return String(format: "%.0fK \(unit)", value / 1000)
}
-
+
+ // MARK: Body
+
var body: some View {
NavigationView {
ZStack {
- // Gradient background matching app style (same as ManualExposureSheet)
LinearGradient(
colors: [Color(hex: "4a90e2"), Color(hex: "7bb7e5")],
startPoint: .topLeading,
@@ -66,128 +80,121 @@ struct SessionCompletionSheet: View {
)
.ignoresSafeArea()
- // Content
VStack(spacing: 0) {
- // Session summary
VStack(spacing: 20) {
- // Sun icon with animation
- Image(systemName: "sun.max.fill")
- .font(.system(size: 60))
- .foregroundColor(.yellow)
- .symbolEffect(.pulse)
- .padding(.top, 10)
-
- // Session stats
- VStack(spacing: 16) {
- // Vitamin D amount
- VStack(spacing: 4) {
- Text("VITAMIN D SYNTHESIZED")
- .font(.system(size: 12, weight: .bold))
- .foregroundColor(.white.opacity(0.7))
- .tracking(1.2)
- Text(formattedAmount)
- .font(.system(size: 36, weight: .bold, design: .rounded))
- .foregroundColor(.white)
+ Image(systemName: "sun.max.fill")
+ .font(.system(size: 60))
+ .foregroundColor(.yellow)
+ .symbolEffect(.pulse)
+ .padding(.top, 10)
+
+ VStack(spacing: 16) {
+ // Vitamin D amount — animates when recalculated
+ VStack(spacing: 4) {
+ Text("VITAMIN D SYNTHESIZED")
+ .font(.system(size: 12, weight: .bold))
+ .foregroundColor(.white.opacity(0.7))
+ .tracking(1.2)
+ Text(formattedAmount)
+ .font(.system(size: 36, weight: .bold, design: .rounded))
+ .foregroundColor(.white)
+ .animation(.easeInOut(duration: 0.25), value: currentAmount)
+ .contentTransition(.numericText())
+ }
+
+ // Duration
+ VStack(spacing: 4) {
+ Text("SESSION DURATION")
+ .font(.system(size: 12, weight: .bold))
+ .foregroundColor(.white.opacity(0.7))
+ .tracking(1.2)
+ Text(formattedDuration)
+ .font(.system(size: 26, weight: .semibold, design: .rounded))
+ .foregroundColor(.white)
+ .animation(.easeInOut(duration: 0.25), value: sessionDuration)
+ .contentTransition(.numericText())
+ }
}
-
- // Duration
- VStack(spacing: 4) {
- Text("SESSION DURATION")
- .font(.system(size: 12, weight: .bold))
- .foregroundColor(.white.opacity(0.7))
- .tracking(1.2)
- Text(formattedDuration)
- .font(.system(size: 26, weight: .semibold, design: .rounded))
- .foregroundColor(.white)
+ .padding(.horizontal, 30)
+
+ VStack(spacing: 16) {
+ // Editable start time
+ VStack(alignment: .leading, spacing: 8) {
+ Text("START TIME")
+ .font(.system(size: 10, weight: .bold))
+ .foregroundColor(.white.opacity(0.6))
+ .tracking(1.2)
+ DatePicker("", selection: $selectedStartTime,
+ in: ...selectedEndTime,
+ displayedComponents: [.hourAndMinute])
+ .datePickerStyle(.wheel)
+ .labelsHidden()
+ .colorScheme(.dark)
+ .frame(height: 100)
+ }
+ .frame(maxWidth: .infinity, alignment: .leading)
+ .padding()
+ .background(Color.black.opacity(0.2))
+ .cornerRadius(12)
+
+ // Editable end time
+ VStack(alignment: .leading, spacing: 8) {
+ Text("END TIME")
+ .font(.system(size: 10, weight: .bold))
+ .foregroundColor(.white.opacity(0.6))
+ .tracking(1.2)
+ DatePicker("", selection: $selectedEndTime,
+ in: selectedStartTime...Date(),
+ displayedComponents: [.hourAndMinute])
+ .datePickerStyle(.wheel)
+ .labelsHidden()
+ .colorScheme(.dark)
+ .frame(height: 100)
+ }
+ .frame(maxWidth: .infinity, alignment: .leading)
+ .padding()
+ .background(Color.black.opacity(0.2))
+ .cornerRadius(12)
}
+ .padding(.horizontal, 20)
+ .padding(.top, 10)
}
- .padding(.horizontal, 30)
-
- // Time adjustment section
- VStack(spacing: 16) {
- // Start time (non-editable)
- VStack(alignment: .leading, spacing: 8) {
- Text("START TIME")
- .font(.system(size: 10, weight: .bold))
- .foregroundColor(.white.opacity(0.6))
- .tracking(1.2)
- Text(formatTime(sessionStartTime))
- .font(.system(size: 20, weight: .semibold))
+
+ Spacer()
+
+ VStack(spacing: 12) {
+ Button(action: { saveSession() }) {
+ HStack {
+ Image(systemName: "heart.fill")
+ .font(.system(size: 18))
+ Text("Save to Health")
+ .font(.system(size: 18, weight: .semibold))
+ }
+ .foregroundColor(.white)
+ .frame(maxWidth: .infinity)
+ .padding(.vertical, 16)
+ .background(Color.white.opacity(0.2))
+ .cornerRadius(15)
+ }
+
+ Button(action: { onCancel(); dismiss() }) {
+ Text("Continue Tracking")
+ .font(.system(size: 16, weight: .medium))
.foregroundColor(.white)
+ .frame(maxWidth: .infinity)
+ .padding(.vertical, 12)
}
- .frame(maxWidth: .infinity, alignment: .leading)
- .padding()
- .background(Color.black.opacity(0.2))
- .cornerRadius(12)
-
- // End time picker
- VStack(alignment: .leading, spacing: 8) {
- Text("END TIME")
- .font(.system(size: 10, weight: .bold))
- .foregroundColor(.white.opacity(0.6))
- .tracking(1.2)
-
- DatePicker("", selection: $selectedEndTime,
- in: sessionStartTime...Date(),
- displayedComponents: [.hourAndMinute])
- .datePickerStyle(.wheel)
- .labelsHidden()
- .colorScheme(.dark)
- .frame(height: 100)
+
+ Button(action: { endWithoutSaving() }) {
+ Text("End and Don't Save")
+ .font(.system(size: 16, weight: .medium))
+ .foregroundColor(.white.opacity(0.7))
+ .frame(maxWidth: .infinity)
+ .padding(.vertical, 12)
}
- .frame(maxWidth: .infinity, alignment: .leading)
- .padding()
- .background(Color.black.opacity(0.2))
- .cornerRadius(12)
}
.padding(.horizontal, 20)
- .padding(.top, 10)
- }
-
- Spacer()
-
- // Action buttons
- VStack(spacing: 12) {
- Button(action: {
- saveSession()
- }) {
- HStack {
- Image(systemName: "heart.fill")
- .font(.system(size: 18))
- Text("Save to Health")
- .font(.system(size: 18, weight: .semibold))
- }
- .foregroundColor(.white)
- .frame(maxWidth: .infinity)
- .padding(.vertical, 16)
- .background(Color.white.opacity(0.2))
- .cornerRadius(15)
- }
-
- Button(action: {
- onCancel()
- dismiss()
- }) {
- Text("Continue Tracking")
- .font(.system(size: 16, weight: .medium))
- .foregroundColor(.white)
- .frame(maxWidth: .infinity)
- .padding(.vertical, 12)
- }
-
- Button(action: {
- // End session without saving
- endWithoutSaving()
- }) {
- Text("End and Don't Save")
- .font(.system(size: 16, weight: .medium))
- .foregroundColor(.white.opacity(0.7))
- .frame(maxWidth: .infinity)
- .padding(.vertical, 12)
- }
- }
- .padding(.horizontal, 20)
- .padding(.bottom, 20)
+ .padding(.bottom, 20)
}
}
.navigationTitle("Session Complete")
@@ -196,44 +203,54 @@ struct SessionCompletionSheet: View {
}
.presentationDetents([.large])
.presentationDragIndicator(.visible)
- // Keep the sheet background clear to match ManualExposureSheet, avoiding white flash
.presentationBackground(.clear)
+ // Recalculate vitamin D 0.5 s after either wheel stops spinning
+ .onChange(of: selectedStartTime) { _, _ in scheduleRecalculation() }
+ .onChange(of: selectedEndTime) { _, _ in scheduleRecalculation() }
}
-
- private func formatTime(_ date: Date) -> String {
- let formatter = DateFormatter()
- formatter.timeStyle = .short
- return formatter.string(from: date)
+
+ // MARK: Recalculation (debounced 0.5 s)
+ //
+ // The accumulated session amount scales linearly with duration — if the user
+ // adds or removes time we apply the same ratio to the original amount.
+ // This preserves the UV / skin / clothing weighting from the live session
+ // without needing to re-run the full kinetics model.
+ private func scheduleRecalculation() {
+ recalcTask?.cancel()
+ recalcTask = Task { @MainActor in
+ try? await Task.sleep(nanoseconds: 500_000_000) // 0.5 s
+ guard !Task.isCancelled, originalDuration > 0 else { return }
+ let newDuration = max(0, selectedEndTime.timeIntervalSince(selectedStartTime))
+ currentAmount = sessionAmount * (newDuration / originalDuration)
+ }
}
-
+
+ // MARK: Helpers
+
private func saveSession() {
- // Save to HealthKit and wait for completion to ensure UI updates reflect the saved sample
- healthManager.saveVitaminD(amount: sessionAmount) { _ in
- // Refresh today's base from Health for accurate totals and widget
+ // Save the recalculated amount (currentAmount) to HealthKit, not the
+ // original sessionAmount, so the Health record matches the adjusted times.
+ healthManager.saveVitaminD(amount: currentAmount, date: selectedStartTime) { _ in
vitaminDCalculator.refreshTodayTotals(forceWidget: true)
- // Create and save session record to SwiftData
let session = VitaminDSession(
- startTime: sessionStartTime,
- totalIU: sessionAmount,
- averageUV: 0, // TODO: Calculate average UV
- peakUV: 0, // TODO: Track peak UV
+ startTime: selectedStartTime,
+ totalIU: currentAmount,
+ averageUV: 0,
+ peakUV: 0,
clothingLevel: vitaminDCalculator.clothingLevel.rawValue,
skinType: vitaminDCalculator.skinType.rawValue
)
session.endTime = selectedEndTime
-
+
modelContext.insert(session)
try? modelContext.save()
-
- // Call completion handler and dismiss
+
onSave()
dismiss()
}
}
-
+
private func endWithoutSaving() {
- // Just end the session without saving to HealthKit
- // Reset session amount before toggling to avoid re-presenting the sheet
vitaminDCalculator.sessionVitaminD = 0.0
vitaminDCalculator.toggleSunExposure(uvIndex: 0)
dismiss()
diff --git a/SundayWidget/Info.plist b/SundayWidget/Info.plist
index 76ed8fd..8859a57 100644
--- a/SundayWidget/Info.plist
+++ b/SundayWidget/Info.plist
@@ -17,7 +17,7 @@
CFBundlePackageType
XPC!
CFBundleShortVersionString
- 1.1.2
+ 1.0
CFBundleVersion
1
NSExtension