From 45d24b50a62466f2540b51d9e7760148b78e0aa7 Mon Sep 17 00:00:00 2001 From: Jesse Wales Date: Sun, 7 Jun 2026 09:54:39 +1000 Subject: [PATCH 1/3] Allow editing session start time in session complete sheet MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replaces the non-editable start time display with a wheel DatePicker, mirroring the existing end time editing behaviour. The start time picker is capped to ≤ selectedEndTime, and the end time picker lower bound updates to selectedStartTime, keeping the range mutually consistent. The adjusted start time is written through to the saved VitaminDSession and HealthKit entry. Co-Authored-By: Claude Sonnet 4.6 --- Sources/Views/SessionCompletionSheet.swift | 27 ++++++++++++++-------- 1 file changed, 17 insertions(+), 10 deletions(-) diff --git a/Sources/Views/SessionCompletionSheet.swift b/Sources/Views/SessionCompletionSheet.swift index 0aeecbf..9fbf660 100644 --- a/Sources/Views/SessionCompletionSheet.swift +++ b/Sources/Views/SessionCompletionSheet.swift @@ -13,18 +13,20 @@ struct SessionCompletionSheet: View { 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) { self.sessionStartTime = sessionStartTime self.sessionAmount = sessionAmount self.onSave = onSave self.onCancel = onCancel + self._selectedStartTime = State(initialValue: sessionStartTime) self._selectedEndTime = State(initialValue: Date()) } - + private var sessionDuration: TimeInterval { - selectedEndTime.timeIntervalSince(sessionStartTime) + selectedEndTime.timeIntervalSince(selectedStartTime) } private var formattedDuration: String { @@ -105,15 +107,20 @@ struct SessionCompletionSheet: View { // Time adjustment section VStack(spacing: 16) { - // Start time (non-editable) + // Start time picker 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)) - .foregroundColor(.white) + + DatePicker("", selection: $selectedStartTime, + in: ...selectedEndTime, + displayedComponents: [.hourAndMinute]) + .datePickerStyle(.wheel) + .labelsHidden() + .colorScheme(.dark) + .frame(height: 100) } .frame(maxWidth: .infinity, alignment: .leading) .padding() @@ -127,8 +134,8 @@ struct SessionCompletionSheet: View { .foregroundColor(.white.opacity(0.6)) .tracking(1.2) - DatePicker("", selection: $selectedEndTime, - in: sessionStartTime...Date(), + DatePicker("", selection: $selectedEndTime, + in: selectedStartTime...Date(), displayedComponents: [.hourAndMinute]) .datePickerStyle(.wheel) .labelsHidden() @@ -213,7 +220,7 @@ struct SessionCompletionSheet: View { vitaminDCalculator.refreshTodayTotals(forceWidget: true) // Create and save session record to SwiftData let session = VitaminDSession( - startTime: sessionStartTime, + startTime: selectedStartTime, totalIU: sessionAmount, averageUV: 0, // TODO: Calculate average UV peakUV: 0, // TODO: Track peak UV From 46e1e834d6123ab1bf293f0a74d484dae4dcb2a2 Mon Sep 17 00:00:00 2001 From: Jesse Wales <265921002+JWAY21@users.noreply.github.com> Date: Mon, 8 Jun 2026 12:21:34 +1000 Subject: [PATCH 2/3] Recalculate vitamin D when session times are adjusted MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When the user edits the start or end time on the Session Complete sheet, the vitamin D synthesized amount now updates automatically 0.5 s after the wheel stops spinning. The new amount scales proportionally to the adjusted duration (newDuration / originalDuration × sessionAmount), preserving the UV/skin/clothing weighting from the live session. Co-Authored-By: Claude Sonnet 4.6 --- Sources/Views/SessionCompletionSheet.swift | 334 +++++++++++---------- 1 file changed, 172 insertions(+), 162 deletions(-) diff --git a/Sources/Views/SessionCompletionSheet.swift b/Sources/Views/SessionCompletionSheet.swift index 9fbf660..0053d53 100644 --- a/Sources/Views/SessionCompletionSheet.swift +++ b/Sources/Views/SessionCompletionSheet.swift @@ -7,60 +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 + /// 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) { + init(sessionStartTime: Date, sessionAmount: Double, + onSave: @escaping () -> Void, onCancel: @escaping () -> Void) { self.sessionStartTime = sessionStartTime self.sessionAmount = sessionAmount self.onSave = onSave self.onCancel = onCancel + let now = Date() self._selectedStartTime = State(initialValue: sessionStartTime) - self._selectedEndTime = State(initialValue: Date()) + self._selectedEndTime = State(initialValue: now) + self._currentAmount = State(initialValue: sessionAmount) + self.originalDuration = now.timeIntervalSince(sessionStartTime) } + // MARK: Computed helpers + private var sessionDuration: TimeInterval { - selectedEndTime.timeIntervalSince(selectedStartTime) + 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, @@ -68,133 +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 picker - 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) + + 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) } - .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: selectedStartTime...Date(), - displayedComponents: [.hourAndMinute]) - .datePickerStyle(.wheel) - .labelsHidden() - .colorScheme(.dark) - .frame(height: 100) + + 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) - } - .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)) + + 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) } - .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(.horizontal, 20) + .padding(.bottom, 20) } } .navigationTitle("Session Complete") @@ -203,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) { _ in vitaminDCalculator.refreshTodayTotals(forceWidget: true) - // Create and save session record to SwiftData let session = VitaminDSession( startTime: selectedStartTime, - totalIU: sessionAmount, - averageUV: 0, // TODO: Calculate average UV - peakUV: 0, // TODO: Track peak UV + 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() From bd5d7afffd9a5718d6b26707af3e2403781b2ebd Mon Sep 17 00:00:00 2001 From: Jesse Wales <265921002+JWAY21@users.noreply.github.com> Date: Mon, 8 Jun 2026 14:12:37 +1000 Subject: [PATCH 3/3] Pass selectedStartTime as date to saveVitaminD Without this the HealthKit sample was always stamped with the moment the user tapped "Save to Health" (Date() default), not with the corrected session start time. The adjusted start time was only written to the SwiftData record, not to HealthKit. Co-Authored-By: Claude Sonnet 4.6 --- Resources/Info.plist | 15 +-------------- Sources/Views/SessionCompletionSheet.swift | 2 +- SundayWidget/Info.plist | 2 +- 3 files changed, 3 insertions(+), 16 deletions(-) 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 0053d53..f5d4e56 100644 --- a/Sources/Views/SessionCompletionSheet.swift +++ b/Sources/Views/SessionCompletionSheet.swift @@ -230,7 +230,7 @@ struct SessionCompletionSheet: View { private func saveSession() { // Save the recalculated amount (currentAmount) to HealthKit, not the // original sessionAmount, so the Health record matches the adjusted times. - healthManager.saveVitaminD(amount: currentAmount) { _ in + healthManager.saveVitaminD(amount: currentAmount, date: selectedStartTime) { _ in vitaminDCalculator.refreshTodayTotals(forceWidget: true) let session = VitaminDSession( startTime: selectedStartTime, 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