From 24695ec94f8068fe127e8e4462d465698491c107 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Fri, 24 Jul 2026 14:27:08 +0000 Subject: [PATCH 1/4] Add HealthKit diagnostic logging and fix permission flow - DiagnosticLogger records HealthKit bootstrap, queries, and state changes. - Settings Diagnostics section: share/copy logs, retry HealthKit, live state. - Grant Access always calls requestAuthorization and shows loading/errors. - Bootstrap tries silent data load first; only prompts when zero samples. - Await HK queries, recover from SwiftData store failures, fix preferences seeding. Co-authored-by: Greg --- Bedtime/Bedtime/BedtimeApp.swift | 21 +- Bedtime/Bedtime/ContentView.swift | 61 ++-- Bedtime/Bedtime/Models/HealthKitManager.swift | 297 ++++++++++++------ Bedtime/Bedtime/Utils/DiagnosticLogger.swift | 77 +++++ .../Bedtime/Views/Components/ShareSheet.swift | 18 ++ .../Views/HealthKitAuthorizationCard.swift | 13 +- Bedtime/Bedtime/Views/LastNightCard.swift | 2 +- Bedtime/Bedtime/Views/SettingsView.swift | 80 +++++ 8 files changed, 437 insertions(+), 132 deletions(-) create mode 100644 Bedtime/Bedtime/Utils/DiagnosticLogger.swift create mode 100644 Bedtime/Bedtime/Views/Components/ShareSheet.swift diff --git a/Bedtime/Bedtime/BedtimeApp.swift b/Bedtime/Bedtime/BedtimeApp.swift index 5288669..04f1be3 100644 --- a/Bedtime/Bedtime/BedtimeApp.swift +++ b/Bedtime/Bedtime/BedtimeApp.swift @@ -18,9 +18,26 @@ struct BedtimeApp: App { let modelConfiguration = ModelConfiguration(schema: schema, isStoredInMemoryOnly: false) do { - return try ModelContainer(for: schema, configurations: [modelConfiguration]) + let container = try ModelContainer(for: schema, configurations: [modelConfiguration]) + DiagnosticLogger.log("ModelContainer opened successfully") + return container } catch { - fatalError("Could not create ModelContainer: \(error)") + DiagnosticLogger.log("ModelContainer failed: \(error.localizedDescription) — resetting store") + let storeURL = modelConfiguration.url + let fileManager = FileManager.default + for suffix in ["", "-shm", "-wal"] { + let url = URL(fileURLWithPath: storeURL.path + suffix) + try? fileManager.removeItem(at: url) + } + + do { + let container = try ModelContainer(for: schema, configurations: [modelConfiguration]) + DiagnosticLogger.log("ModelContainer opened after store reset") + return container + } catch { + DiagnosticLogger.log("ModelContainer failed after store reset: \(error.localizedDescription)") + fatalError("Could not create ModelContainer after resetting the store: \(error)") + } } }() diff --git a/Bedtime/Bedtime/ContentView.swift b/Bedtime/Bedtime/ContentView.swift index 24fba95..dde831f 100644 --- a/Bedtime/Bedtime/ContentView.swift +++ b/Bedtime/Bedtime/ContentView.swift @@ -22,6 +22,7 @@ struct ContentView: View { let sourcePrefs = SourcePreferences() _sourcePreferences = StateObject(wrappedValue: sourcePrefs) _healthKitManager = StateObject(wrappedValue: HealthKitManager(sourcePreferences: sourcePrefs)) + DiagnosticLogger.log("ContentView initialized") } var lastNightData: [SleepSession]? { @@ -30,39 +31,49 @@ struct ContentView: View { return healthKitManager.sleepSessions[lastNight] } - private var userPreferences: UserPreferences { - if let existing = preferences.first { - return existing - } else { - let new = UserPreferences() - modelContext.insert(new) - return new - } - } - - private var sleepBank: SleepBank { + private func sleepBank(for preferences: UserPreferences) -> SleepBank { ViewModel.calculateSleepBank( sleepSessions: healthKitManager.sleepSessions, - goalHours: userPreferences.sleepGoalHours, - recentDays: userPreferences.sleepBankDays + goalHours: preferences.sleepGoalHours, + recentDays: preferences.sleepBankDays ) } - private var bedtimeRecommendation: BedtimeRecommendation { + private func bedtimeRecommendation( + for preferences: UserPreferences, + sleepBank: SleepBank + ) -> BedtimeRecommendation { ViewModel.generateBedtimeRecommendation( - wakeTime: userPreferences.wakeTime, - earliestBedtime: userPreferences.earliestReasonableBedtime, - sleepGoal: userPreferences.sleepGoalHours, + wakeTime: preferences.wakeTime, + earliestBedtime: preferences.earliestReasonableBedtime, + sleepGoal: preferences.sleepGoalHours, sleepBank: sleepBank ) } var body: some View { + if let userPreferences = preferences.first { + mainContent(userPreferences: userPreferences) + .task { + await healthKitManager.resumeLoadingIfNeeded() + } + } else { + ProgressView() + .task { + seedDefaultPreferencesIfNeeded() + } + } + } + + @ViewBuilder + private func mainContent(userPreferences: UserPreferences) -> some View { let isBeforeEvening = Calendar.current.component(.hour, from: Date()) < 18 + let sleepBank = sleepBank(for: userPreferences) + let bedtimeRecommendation = bedtimeRecommendation(for: userPreferences, sleepBank: sleepBank) + NavigationStack { ScrollView { VStack(spacing: 20) { - // HealthKit Authorization switch healthKitManager.permissionsRequestState { case .loading: ProgressView() @@ -87,7 +98,6 @@ struct ContentView: View { goal: userPreferences.sleepGoalHours) } - // Recent Sleep Sessions if !healthKitManager.sleepSessions.isEmpty { RecentSleepSessionsCard(sessions: healthKitManager.sleepSessions, sleepGoal: userPreferences.sleepGoalHours) } @@ -128,10 +138,16 @@ struct ContentView: View { ) } } - .task { - try? await healthKitManager.fetchSleepData() + .onChange(of: healthKitManager.permissionsRequestState) { _, newState in + DiagnosticLogger.log("permissionsRequestState → \(newState)") } } + + private func seedDefaultPreferencesIfNeeded() { + guard preferences.isEmpty else { return } + DiagnosticLogger.log("Seeding default UserPreferences") + modelContext.insert(UserPreferences()) + } } #Preview { @@ -140,8 +156,6 @@ struct ContentView: View { } private extension View { - /// Presents settings as an inspector pane when `useInspector` is true (iPad regular width) - /// and as a sheet otherwise (iPhone / iPad split-screen). @ViewBuilder func settingsPresentation( isPresented: Binding, @@ -158,4 +172,3 @@ private extension View { } } } - diff --git a/Bedtime/Bedtime/Models/HealthKitManager.swift b/Bedtime/Bedtime/Models/HealthKitManager.swift index 489b5ca..60f3b40 100644 --- a/Bedtime/Bedtime/Models/HealthKitManager.swift +++ b/Bedtime/Bedtime/Models/HealthKitManager.swift @@ -9,20 +9,23 @@ import Foundation import HealthKit import Combine -/// HealthKit intentionally does **not** report whether read access was granted — -/// `requestAuthorization` succeeding only means the sheet was dismissed. There is -/// no separate "already has permission" case; returning users reach `.hasRequested` -/// when the silent re-check completes without showing the sheet. -/// /// HealthKit intentionally does **not** report whether read access was granted — /// `requestAuthorization` succeeding only means the user chose /// whether or not to provide permission. We use this flag to avoid re-prompting, /// not as proof of access. Write/share permission (for debug) is handled separately by /// `requireWriteAuthorization(for:)`, which can re-prompt when needed. -enum PermissionsRequestState: Equatable { +enum PermissionsRequestState: Equatable, CustomStringConvertible { case loading case shouldRequest case hasRequested + + var description: String { + switch self { + case .loading: return "loading" + case .shouldRequest: return "shouldRequest" + case .hasRequested: return "hasRequested" + } + } } @MainActor @@ -33,6 +36,7 @@ class HealthKitManager: ObservableObject { private var cancellables = Set() @Published private(set) var permissionsRequestState: PermissionsRequestState = .loading + @Published private(set) var isRequestingAccess = false @Published var sleepSessions: [Date: [SleepSession]] = [:] @Published var errorMessage: String? @Published var availableSources: [HKSource]? @@ -40,142 +44,235 @@ class HealthKitManager: ObservableObject { init(sourcePreferences: SourcePreferences) { self.sourcePreferences = sourcePreferences - do { - try checkHealthKitAvailability() - } catch { - errorMessage = error.localizedDescription - permissionsRequestState = .shouldRequest - } - - // Listen for preference changes to re-filter data immediately sourcePreferences.objectWillChange .debounce(for: .milliseconds(100), scheduler: DispatchQueue.main) .sink { [weak self] _ in self?.reprocessStoredSamples() } .store(in: &cancellables) + + Task { @MainActor in + await self.bootstrap() + } } - private func checkHealthKitAvailability() throws { - guard HKHealthStore.isHealthDataAvailable() else { - throw NSError(domain: "HealthKitManager", code: 1, userInfo: [NSLocalizedDescriptionKey: "HealthKit is not available on this device"]) + private func bootstrap() async { + DiagnosticLogger.log("HealthKit bootstrap — state=\(permissionsRequestState)") + do { + try checkHealthKitAvailability() + DiagnosticLogger.log("HealthKit is available") + } catch { + errorMessage = error.localizedDescription + permissionsRequestState = .shouldRequest + DiagnosticLogger.log("HealthKit unavailable: \(error.localizedDescription)") + return } + + logAuthorizationHints() + + // Try loading without prompting first — works when the user already granted access. + await attemptSilentDataLoad() } - - /// Presents the HealthKit authorization sheet for read access if we haven't - /// already. No-op on subsequent calls — see `PermissionsRequestState` for - /// why we can't verify if read access was actually granted. - func requestAuthorization() async throws { - guard permissionsRequestState != .hasRequested else { return } - - try checkHealthKitAvailability() - + + /// Called from the "Grant Access" button. Always presents (or re-presents) the + /// HealthKit authorization flow, then reloads data. + func requestAccessFromUser() async { + guard !isRequestingAccess else { + DiagnosticLogger.log("requestAccessFromUser ignored — already in progress") + return + } + + isRequestingAccess = true + errorMessage = nil + DiagnosticLogger.log("User tapped Grant Access") + + defer { isRequestingAccess = false } + do { + try checkHealthKitAvailability() + logAuthorizationHints() + DiagnosticLogger.log("Calling requestAuthorization for sleep analysis…") try await healthStore.requestAuthorization( toShare: [], read: [HKCategoryType.sleepAnalysis] ) + DiagnosticLogger.log("requestAuthorization returned") permissionsRequestState = .hasRequested + try await loadSleepData() + logLoadResults(context: "after user grant") + if rawSleepSamples.isEmpty { + errorMessage = permissionDeniedOrNoDataMessage + } } catch { - throw NSError(domain: "HealthKitManager", code: 2, userInfo: [NSLocalizedDescriptionKey: "Failed to request HealthKit authorization: \(error.localizedDescription)"]) + permissionsRequestState = .shouldRequest + errorMessage = error.localizedDescription + DiagnosticLogger.log("requestAccessFromUser failed: \(error.localizedDescription)") } } - + func fetchSleepData() async throws { - defer { - if permissionsRequestState == .loading { - permissionsRequestState = .shouldRequest + DiagnosticLogger.log("fetchSleepData — state=\(permissionsRequestState)") + do { + if permissionsRequestState != .hasRequested { + try await healthStore.requestAuthorization( + toShare: [], + read: [HKCategoryType.sleepAnalysis] + ) + permissionsRequestState = .hasRequested + DiagnosticLogger.log("fetchSleepData authorized") } + try await loadSleepData() + logLoadResults(context: "fetchSleepData") + } catch { + errorMessage = error.localizedDescription + DiagnosticLogger.log("fetchSleepData error: \(error.localizedDescription)") + throw error } + } + + func resumeLoadingIfNeeded() async { + guard permissionsRequestState == .loading else { return } + DiagnosticLogger.log("resumeLoadingIfNeeded") + await bootstrap() + } + + private func attemptSilentDataLoad() async { + DiagnosticLogger.log("Attempting silent data load (no auth prompt)") do { - try await requestAuthorization() try await loadSleepData() + logLoadResults(context: "silent load") + if rawSleepSamples.isEmpty { + permissionsRequestState = .shouldRequest + DiagnosticLogger.log("Silent load returned 0 samples — showing permission UI") + } else { + permissionsRequestState = .hasRequested + DiagnosticLogger.log("Silent load succeeded — skipping permission UI") + } } catch { + permissionsRequestState = .shouldRequest errorMessage = error.localizedDescription - throw error + DiagnosticLogger.log("Silent load error: \(error.localizedDescription)") + } + } + + private var permissionDeniedOrNoDataMessage: String { + "No sleep data was returned from Apple Health. If you previously denied access, open Settings → Health → Data Access & Devices → Bedger and turn on Sleep." + } + + private func logLoadResults(context: String) { + let sourceNames = availableSources?.map(\.name).joined(separator: ", ") ?? "none" + DiagnosticLogger.log( + "\(context): rawSamples=\(rawSleepSamples.count), " + + "groupedDays=\(sleepSessions.count), " + + "sources=\(availableSources?.count ?? 0) [\(sourceNames)]" + ) + } + + private func logAuthorizationHints() { + let writeStatus = healthStore.authorizationStatus(for: HKCategoryType.sleepAnalysis) + DiagnosticLogger.log( + "Sleep analysis write/share authorizationStatus=\(writeStatus.rawValue) " + + "(HealthKit does not expose read authorization status)" + ) + } + + private func checkHealthKitAvailability() throws { + guard HKHealthStore.isHealthDataAvailable() else { + throw NSError(domain: "HealthKitManager", code: 1, userInfo: [NSLocalizedDescriptionKey: "HealthKit is not available on this device"]) } } private func loadSleepData() async throws { - _ = try await [fetchSleepDataForDisplay(), discoverAvailableSources()] + try await fetchSleepDataForDisplay() + await discoverAvailableSources() } private func fetchSleepDataForDisplay() async throws { let calendar = Calendar.current let endDate = Date() let today = calendar.startOfDay(for: endDate) - // Fetch one extra day before the UI range: grouping (midpoint + 6h) can assign - // sessions that start the previous evening to the oldest displayed day, including - // short blocks (e.g. 9–11pm) as well as overnight sleep. guard let startDate = calendar.date(byAdding: .day, value: -Constants.sleepHistoryDays, to: today) else { throw NSError(domain: "HealthKitManager", code: 1, userInfo: [NSLocalizedDescriptionKey: "Failed to calculate start date"]) } + + DiagnosticLogger.log("Querying sleep samples from \(startDate) to \(endDate)") let predicate = HKQuery.predicateForSamples(withStart: startDate, end: endDate, options: .strictStartDate) - let query = HKSampleQuery( - sampleType: HKCategoryType.sleepAnalysis, - predicate: predicate, - limit: HKObjectQueryNoLimit, - sortDescriptors: [NSSortDescriptor(key: HKSampleSortIdentifierStartDate, ascending: false)] - ) { [weak self] _, samples, error in - DispatchQueue.main.async { - if let error = error { - self?.errorMessage = "Failed to fetch sleep data: \(error.localizedDescription)" - return - } - - guard let samples = samples as? [HKCategorySample] else { - self?.errorMessage = "No sleep data found" - return + try await withCheckedThrowingContinuation { (continuation: CheckedContinuation) in + let query = HKSampleQuery( + sampleType: HKCategoryType.sleepAnalysis, + predicate: predicate, + limit: HKObjectQueryNoLimit, + sortDescriptors: [NSSortDescriptor(key: HKSampleSortIdentifierStartDate, ascending: false)] + ) { [weak self] _, samples, error in + DispatchQueue.main.async { + if let error = error { + DiagnosticLogger.log("Display query error: \(error.localizedDescription)") + self?.errorMessage = "Failed to fetch sleep data: \(error.localizedDescription)" + continuation.resume(throwing: error) + return + } + + guard let samples = samples as? [HKCategorySample] else { + DiagnosticLogger.log("Display query returned no samples (nil cast)") + self?.errorMessage = "No sleep data found" + continuation.resume() + return + } + + DiagnosticLogger.log("Display query returned \(samples.count) raw samples") + self?.rawSleepSamples = samples + self?.processSleepSamples(samples) + continuation.resume() } - - self?.rawSleepSamples = samples - self?.processSleepSamples(samples) } + + healthStore.execute(query) } - - healthStore.execute(query) } - private func discoverAvailableSources() async throws { - // Query all time to discover all sources that have ever provided sleep data - // Use a very old start date to get all historical data - let predicate = HKQuery.predicateForSamples( - withStart: Date.distantPast, - end: Date(), - options: .strictStartDate - ) - - let query = HKSampleQuery( - sampleType: HKCategoryType.sleepAnalysis, - predicate: predicate, - limit: HKObjectQueryNoLimit, - sortDescriptors: [NSSortDescriptor(key: HKSampleSortIdentifierStartDate, ascending: false)] - ) { [weak self] _, samples, error in - DispatchQueue.main.async { - if let error = error { - // Don't fail if we can't discover sources, just log it - print("Failed to discover sources: \(error.localizedDescription)") - return - } - - guard let samples = samples as? [HKCategorySample] else { - return - } - - // Extract unique sources from all samples - let uniqueSources = Dictionary(grouping: samples) { $0.sourceRevision.source.bundleIdentifier } - .compactMap { _, samples -> HKSource? in - samples.first?.sourceRevision.source + private func discoverAvailableSources() async { + DiagnosticLogger.log("Discovering sleep data sources…") + await withCheckedContinuation { (continuation: CheckedContinuation) in + let predicate = HKQuery.predicateForSamples( + withStart: Date.distantPast, + end: Date(), + options: .strictStartDate + ) + + let query = HKSampleQuery( + sampleType: HKCategoryType.sleepAnalysis, + predicate: predicate, + limit: HKObjectQueryNoLimit, + sortDescriptors: [NSSortDescriptor(key: HKSampleSortIdentifierStartDate, ascending: false)] + ) { [weak self] _, samples, error in + DispatchQueue.main.async { + defer { continuation.resume() } + + if let error = error { + DiagnosticLogger.log("Source discovery error: \(error.localizedDescription)") + return } - .sorted { $0.name < $1.name } - - self?.availableSources = uniqueSources + + guard let samples = samples as? [HKCategorySample] else { + DiagnosticLogger.log("Source discovery returned no samples") + return + } + + let uniqueSources = Dictionary(grouping: samples) { $0.sourceRevision.source.bundleIdentifier } + .compactMap { _, samples -> HKSource? in + samples.first?.sourceRevision.source + } + .sorted { $0.name < $1.name } + + self?.availableSources = uniqueSources + DiagnosticLogger.log("Discovered \(uniqueSources.count) sources: \(uniqueSources.map(\.name).joined(separator: ", "))") + } } + + healthStore.execute(query) } - - healthStore.execute(query) } private func reprocessStoredSamples() { @@ -183,23 +280,21 @@ class HealthKitManager: ObservableObject { } private func processSleepSamples(_ samples: [HKCategorySample]) { - // Filter based on user's source preferences let sessions = samples .filter { sourcePreferences.isSourceSelected($0.sourceRevision.source.bundleIdentifier) } .compactMap { SleepSession(sample: $0) } + + DiagnosticLogger.log( + "Processed \(samples.count) samples → \(sessions.count) sleep sessions " + + "(filtered by source preferences)" + ) self.sleepSessions = Dictionary(grouping: sessions) { $0.dateForGrouping } } #if DEBUG - /// Prompts for write access to `type` (plus read access to sleep analysis), - /// then verifies share authorization succeeded. Unlike read access, HealthKit - /// does report write/share status via `authorizationStatus(for:)`. - /// - /// Re-prompts when needed — e.g. after a read-only authorization — so callers - /// don't need to invoke `requestAuthorization()` first. func requireWriteAuthorization(for type: HKSampleType) async throws { try checkHealthKitAvailability() @@ -237,8 +332,6 @@ class HealthKitManager: ObservableObject { } } - /// Writes a batch of fake sleep nights into HealthKit and refreshes the - /// in-memory cache so the UI updates immediately. Debug builds only. func generateFakeSleepData(nights: Int = 14, targetSleepHours: Double = 7.5) async throws { try await requireWriteAuthorization(for: HKCategoryType.sleepAnalysis) try await DebugDataGenerator.generateFakeSleepData( @@ -249,8 +342,6 @@ class HealthKitManager: ObservableObject { try await fetchSleepData() } - /// Deletes every sample previously written by this app's debug utilities - /// (real samples are untouched). func clearFakeSleepData() async throws { try await requireWriteAuthorization(for: HKCategoryType.sleepAnalysis) try await DebugDataGenerator.clearFakeSleepData(in: healthStore) diff --git a/Bedtime/Bedtime/Utils/DiagnosticLogger.swift b/Bedtime/Bedtime/Utils/DiagnosticLogger.swift new file mode 100644 index 0000000..f8eb349 --- /dev/null +++ b/Bedtime/Bedtime/Utils/DiagnosticLogger.swift @@ -0,0 +1,77 @@ +// +// DiagnosticLogger.swift +// Bedtime +// + +import Foundation +import UIKit + +/// In-memory diagnostic log for TestFlight troubleshooting. Entries can be +/// shared from Settings via the system share sheet (Messages, Mail, copy, etc.). +@MainActor +final class DiagnosticLogger: ObservableObject { + static let shared = DiagnosticLogger() + + @Published private(set) var entries: [String] = [] + + private let maxEntries = 500 + private let dateFormatter: DateFormatter = { + let formatter = DateFormatter() + formatter.dateFormat = "yyyy-MM-dd HH:mm:ss.SSS" + return formatter + }() + + private init() { + log("Diagnostic logger started") + log(deviceContext()) + } + + static func log( + _ message: String, + file: String = #file, + function: String = #function, + line: Int = #line + ) { + Task { @MainActor in + shared.append(message, file: file, function: function, line: line) + } + } + + func exportText() -> String { + let version = Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "?" + let build = Bundle.main.infoDictionary?["CFBundleVersion"] as? String ?? "?" + + var text = """ + Bedger Diagnostic Log + Generated: \(dateFormatter.string(from: Date())) + Bundle ID: \(Bundle.main.bundleIdentifier ?? "unknown") + Version: \(version) (\(build)) + iOS: \(UIDevice.current.systemVersion) + Device: \(UIDevice.current.model) + + --- Events --- + + """ + text += entries.joined(separator: "\n") + return text + } + + func clear() { + entries.removeAll() + log("Log cleared") + } + + private func append(_ message: String, file: String, function: String, line: Int) { + let filename = (file as NSString).lastPathComponent + let entry = "[\(dateFormatter.string(from: Date()))] \(filename):\(line) — \(message)" + entries.append(entry) + if entries.count > maxEntries { + entries.removeFirst(entries.count - maxEntries) + } + print(entry) + } + + private func deviceContext() -> String { + "Device context — iOS \(UIDevice.current.systemVersion), model \(UIDevice.current.model)" + } +} diff --git a/Bedtime/Bedtime/Views/Components/ShareSheet.swift b/Bedtime/Bedtime/Views/Components/ShareSheet.swift new file mode 100644 index 0000000..86ec263 --- /dev/null +++ b/Bedtime/Bedtime/Views/Components/ShareSheet.swift @@ -0,0 +1,18 @@ +// +// ShareSheet.swift +// Bedtime +// + +import SwiftUI +import UIKit + +/// Presents the system share sheet so the user can copy, email, or message content. +struct ShareSheet: UIViewControllerRepresentable { + let items: [Any] + + func makeUIViewController(context: Context) -> UIActivityViewController { + UIActivityViewController(activityItems: items, applicationActivities: nil) + } + + func updateUIViewController(_ uiViewController: UIActivityViewController, context: Context) {} +} diff --git a/Bedtime/Bedtime/Views/HealthKitAuthorizationCard.swift b/Bedtime/Bedtime/Views/HealthKitAuthorizationCard.swift index 4be27fa..f6b971f 100644 --- a/Bedtime/Bedtime/Views/HealthKitAuthorizationCard.swift +++ b/Bedtime/Bedtime/Views/HealthKitAuthorizationCard.swift @@ -35,13 +35,22 @@ struct HealthKitAuthorizationCard: View { .multilineTextAlignment(.leading) } - Button("Grant Access") { + Button { Task { - try await healthKitManager.fetchSleepData() + await healthKitManager.requestAccessFromUser() + } + } label: { + if healthKitManager.isRequestingAccess { + ProgressView() + .frame(maxWidth: .infinity) + } else { + Text("Grant Access") + .frame(maxWidth: .infinity) } } .buttonStyle(.borderedProminent) .controlSize(.large) + .disabled(healthKitManager.isRequestingAccess) } } } diff --git a/Bedtime/Bedtime/Views/LastNightCard.swift b/Bedtime/Bedtime/Views/LastNightCard.swift index a1148d7..a64cd31 100644 --- a/Bedtime/Bedtime/Views/LastNightCard.swift +++ b/Bedtime/Bedtime/Views/LastNightCard.swift @@ -30,7 +30,7 @@ struct LastNightCard: View { title: "Last Night" ) - if let sleepSessions { + if let sleepSessions, !sleepSessions.isEmpty { HStack { VStack(alignment: .leading) { Text("In bed at") diff --git a/Bedtime/Bedtime/Views/SettingsView.swift b/Bedtime/Bedtime/Views/SettingsView.swift index c504a49..600656f 100644 --- a/Bedtime/Bedtime/Views/SettingsView.swift +++ b/Bedtime/Bedtime/Views/SettingsView.swift @@ -8,6 +8,7 @@ import SwiftUI import Combine import HealthKit +import UIKit struct SettingsView: View { @Bindable var preferences: UserPreferences @@ -28,6 +29,12 @@ struct SettingsView: View { @State private var debugMessage: String? #endif + @State private var showingShareSheet = false + @State private var copiedLogsConfirmation = false + @State private var diagnosticsMessage: String? + @State private var isRetryingHealthKit = false + @ObservedObject private var diagnosticLogger = DiagnosticLogger.shared + private var earliestBedtimeBinding: Binding { $preferences.earliestReasonableBedtime } @@ -144,6 +151,69 @@ struct SettingsView: View { } } + Section("Diagnostics") { + LabeledContent("HealthKit state") { + Text(String(describing: healthKitManager.permissionsRequestState)) + .foregroundStyle(.secondary) + } + + LabeledContent("Sleep samples") { + Text("\(healthKitManager.sleepSessions.values.flatMap { $0 }.count)") + .foregroundStyle(.secondary) + } + + LabeledContent("Log entries") { + Text("\(diagnosticLogger.entries.count)") + .foregroundStyle(.secondary) + } + + Button { + showingShareSheet = true + } label: { + Label("Share Diagnostic Logs", systemImage: "square.and.arrow.up") + } + + Button { + UIPasteboard.general.string = diagnosticLogger.exportText() + copiedLogsConfirmation = true + } label: { + Label("Copy Logs to Clipboard", systemImage: "doc.on.doc") + } + + Button { + retryHealthKitAccess() + } label: { + if isRetryingHealthKit { + HStack { + ProgressView() + Text("Retrying…") + } + } else { + Label("Retry HealthKit Access", systemImage: "arrow.clockwise") + } + } + .disabled(isRetryingHealthKit) + + if copiedLogsConfirmation { + Text("Logs copied. Paste into a message or email to send to the developer.") + .font(.caption) + .foregroundColor(.green) + } + + if let diagnosticsMessage { + Text(diagnosticsMessage) + .font(.caption) + .foregroundColor(.secondary) + } + + Text("If sleep data isn't loading, share or copy these logs and send them to the developer.") + .font(.caption) + .foregroundColor(.secondary) + } + .sheet(isPresented: $showingShareSheet) { + ShareSheet(items: [diagnosticLogger.exportText()]) + } + #if DEBUG Section("Developer") { Button { @@ -205,6 +275,16 @@ struct SettingsView: View { } } + private func retryHealthKitAccess() { + isRetryingHealthKit = true + diagnosticsMessage = nil + Task { @MainActor in + await healthKitManager.requestAccessFromUser() + diagnosticsMessage = healthKitManager.errorMessage ?? "HealthKit access retry finished." + isRetryingHealthKit = false + } + } + #if DEBUG /// Runs a debug action while toggling the working state and surfacing /// either a success message or the error description in the UI. From 311b05ae82fe26e665491b3696769b6a5244c5a2 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Fri, 24 Jul 2026 15:52:55 +0000 Subject: [PATCH 2/4] Fix DiagnosticLogger Swift 6 concurrency build error Make log() nonisolated so it can be called from BedtimeApp's static ModelContainer initializer. Move UI-published mutations to @MainActor record() and remove init-time logging that referenced shared during init. Co-authored-by: Greg --- Bedtime/Bedtime/BedtimeApp.swift | 3 +++ Bedtime/Bedtime/Utils/DiagnosticLogger.swift | 24 ++++++++------------ 2 files changed, 13 insertions(+), 14 deletions(-) diff --git a/Bedtime/Bedtime/BedtimeApp.swift b/Bedtime/Bedtime/BedtimeApp.swift index 04f1be3..74dfff9 100644 --- a/Bedtime/Bedtime/BedtimeApp.swift +++ b/Bedtime/Bedtime/BedtimeApp.swift @@ -44,6 +44,9 @@ struct BedtimeApp: App { var body: some Scene { WindowGroup { ContentView() + .onAppear { + DiagnosticLogger.log("App window appeared") + } } .modelContainer(sharedModelContainer) } diff --git a/Bedtime/Bedtime/Utils/DiagnosticLogger.swift b/Bedtime/Bedtime/Utils/DiagnosticLogger.swift index f8eb349..8150eae 100644 --- a/Bedtime/Bedtime/Utils/DiagnosticLogger.swift +++ b/Bedtime/Bedtime/Utils/DiagnosticLogger.swift @@ -8,7 +8,6 @@ import UIKit /// In-memory diagnostic log for TestFlight troubleshooting. Entries can be /// shared from Settings via the system share sheet (Messages, Mail, copy, etc.). -@MainActor final class DiagnosticLogger: ObservableObject { static let shared = DiagnosticLogger() @@ -21,22 +20,22 @@ final class DiagnosticLogger: ObservableObject { return formatter }() - private init() { - log("Diagnostic logger started") - log(deviceContext()) - } + private init() {} - static func log( + /// Safe to call from any isolation context (including static initializers). + nonisolated static func log( _ message: String, file: String = #file, function: String = #function, line: Int = #line ) { + let filename = (file as NSString).lastPathComponent Task { @MainActor in - shared.append(message, file: file, function: function, line: line) + shared.record(message, filename: filename, line: line) } } + @MainActor func exportText() -> String { let version = Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "?" let build = Bundle.main.infoDictionary?["CFBundleVersion"] as? String ?? "?" @@ -56,13 +55,14 @@ final class DiagnosticLogger: ObservableObject { return text } + @MainActor func clear() { entries.removeAll() - log("Log cleared") + Self.log("Log cleared") } - private func append(_ message: String, file: String, function: String, line: Int) { - let filename = (file as NSString).lastPathComponent + @MainActor + private func record(_ message: String, filename: String, line: Int) { let entry = "[\(dateFormatter.string(from: Date()))] \(filename):\(line) — \(message)" entries.append(entry) if entries.count > maxEntries { @@ -70,8 +70,4 @@ final class DiagnosticLogger: ObservableObject { } print(entry) } - - private func deviceContext() -> String { - "Device context — iOS \(UIDevice.current.systemVersion), model \(UIDevice.current.model)" - } } From 35a3ac66efdcce51cfccef572e34b582e48dd4c1 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Fri, 24 Jul 2026 16:04:27 +0000 Subject: [PATCH 3/4] Import Combine for DiagnosticLogger ObservableObject conformance Co-authored-by: Greg --- Bedtime/Bedtime/Utils/DiagnosticLogger.swift | 1 + 1 file changed, 1 insertion(+) diff --git a/Bedtime/Bedtime/Utils/DiagnosticLogger.swift b/Bedtime/Bedtime/Utils/DiagnosticLogger.swift index 8150eae..dc046f6 100644 --- a/Bedtime/Bedtime/Utils/DiagnosticLogger.swift +++ b/Bedtime/Bedtime/Utils/DiagnosticLogger.swift @@ -3,6 +3,7 @@ // Bedtime // +import Combine import Foundation import UIKit From 06c2eaa13edd05c7e716d1702714469428d07d4d Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Fri, 24 Jul 2026 16:51:52 +0000 Subject: [PATCH 4/4] Fix diagnostic share sheet dismissing Settings Present UIActivityViewController from the topmost view controller instead of nesting a SwiftUI sheet inside the Settings sheet. Co-authored-by: Greg --- .../Bedtime/Views/Components/ShareSheet.swift | 46 ++++++++++++++++--- Bedtime/Bedtime/Views/SettingsView.swift | 6 +-- 2 files changed, 40 insertions(+), 12 deletions(-) diff --git a/Bedtime/Bedtime/Views/Components/ShareSheet.swift b/Bedtime/Bedtime/Views/Components/ShareSheet.swift index 86ec263..ae0ca62 100644 --- a/Bedtime/Bedtime/Views/Components/ShareSheet.swift +++ b/Bedtime/Bedtime/Views/Components/ShareSheet.swift @@ -3,16 +3,48 @@ // Bedtime // -import SwiftUI import UIKit -/// Presents the system share sheet so the user can copy, email, or message content. -struct ShareSheet: UIViewControllerRepresentable { - let items: [Any] +/// Presents the system share sheet from the topmost view controller. +/// +/// Avoids nesting a SwiftUI `.sheet` inside the Settings sheet, which causes +/// UIActivityViewController to dismiss immediately along with Settings. +enum SharePresenter { + static func present(items: [Any]) { + guard let presenter = topViewController() else { return } - func makeUIViewController(context: Context) -> UIActivityViewController { - UIActivityViewController(activityItems: items, applicationActivities: nil) + let controller = UIActivityViewController( + activityItems: items, + applicationActivities: nil + ) + + if let popover = controller.popoverPresentationController { + popover.sourceView = presenter.view + popover.sourceRect = CGRect( + x: presenter.view.bounds.midX, + y: presenter.view.bounds.midY, + width: 1, + height: 1 + ) + popover.permittedArrowDirections = [] + } + + presenter.present(controller, animated: true) } - func updateUIViewController(_ uiViewController: UIActivityViewController, context: Context) {} + private static func topViewController() -> UIViewController? { + guard let scene = UIApplication.shared.connectedScenes + .compactMap({ $0 as? UIWindowScene }) + .first(where: { $0.activationState == .foregroundActive }), + let root = scene.windows.first(where: \.isKeyWindow)?.rootViewController + else { + return nil + } + + var top = root + while let presented = top.presentedViewController { + top = presented + } + return top + } } diff --git a/Bedtime/Bedtime/Views/SettingsView.swift b/Bedtime/Bedtime/Views/SettingsView.swift index 600656f..9117ca0 100644 --- a/Bedtime/Bedtime/Views/SettingsView.swift +++ b/Bedtime/Bedtime/Views/SettingsView.swift @@ -29,7 +29,6 @@ struct SettingsView: View { @State private var debugMessage: String? #endif - @State private var showingShareSheet = false @State private var copiedLogsConfirmation = false @State private var diagnosticsMessage: String? @State private var isRetryingHealthKit = false @@ -168,7 +167,7 @@ struct SettingsView: View { } Button { - showingShareSheet = true + SharePresenter.present(items: [diagnosticLogger.exportText()]) } label: { Label("Share Diagnostic Logs", systemImage: "square.and.arrow.up") } @@ -210,9 +209,6 @@ struct SettingsView: View { .font(.caption) .foregroundColor(.secondary) } - .sheet(isPresented: $showingShareSheet) { - ShareSheet(items: [diagnosticLogger.exportText()]) - } #if DEBUG Section("Developer") {