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