diff --git a/Bedtime/Bedtime/BedtimeApp.swift b/Bedtime/Bedtime/BedtimeApp.swift index 98271ac..4655586 100644 --- a/Bedtime/Bedtime/BedtimeApp.swift +++ b/Bedtime/Bedtime/BedtimeApp.swift @@ -18,13 +18,16 @@ 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 { // The store on disk can be incompatible with the current schema after a // model change that SwiftData can't lightweight-migrate (e.g. the max-hours // → earliestReasonableBedtime refactor). Rather than crash on open for // anyone upgrading, discard the stale store and rebuild it. UserPreferences // only holds user settings, which fall back to sensible defaults. + DiagnosticLogger.log("ModelContainer failed: \(error.localizedDescription) — resetting store") if let storeURL = modelConfiguration.url as URL? { let fileManager = FileManager.default for suffix in ["", "-shm", "-wal"] { @@ -34,8 +37,11 @@ struct BedtimeApp: App { } do { - return try ModelContainer(for: schema, configurations: [modelConfiguration]) + 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)") } } @@ -44,6 +50,9 @@ struct BedtimeApp: App { var body: some Scene { WindowGroup { ContentView() + .onAppear { + DiagnosticLogger.log("App window appeared") + } } .modelContainer(sharedModelContainer) } diff --git a/Bedtime/Bedtime/ContentView.swift b/Bedtime/Bedtime/ContentView.swift index 81cfafb..978742b 100644 --- a/Bedtime/Bedtime/ContentView.swift +++ b/Bedtime/Bedtime/ContentView.swift @@ -216,6 +216,9 @@ struct ContentView: View { guard newPhase == .active else { return } Task { try? await healthKitManager.fetchSleepData() } } + .onChange(of: healthKitManager.permissionsRequestState) { _, newState in + DiagnosticLogger.log("permissionsRequestState → \(newState)") + } } } diff --git a/Bedtime/Bedtime/Models/HealthKitManager.swift b/Bedtime/Bedtime/Models/HealthKitManager.swift index ef14cd0..1a6661a 100644 --- a/Bedtime/Bedtime/Models/HealthKitManager.swift +++ b/Bedtime/Bedtime/Models/HealthKitManager.swift @@ -19,10 +19,18 @@ import Combine /// 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 @@ -39,6 +47,8 @@ class HealthKitManager: ObservableObject { private var currentLoad: Task? @Published private(set) var permissionsRequestState: PermissionsRequestState = .loading + /// Drives the Grant Access button's spinner so repeat taps can't stack prompts. + @Published private(set) var isRequestingAccess = false @Published var sleepSessions: [Date: [SleepSession]] = [:] /// All sessions regardless of source preferences — used for per-source comparison UI. @Published private(set) var allSleepSessions: [Date: [SleepSession]] = [:] @@ -50,9 +60,11 @@ class HealthKitManager: ObservableObject { do { try checkHealthKitAvailability() + DiagnosticLogger.log("HealthKit is available") } catch { errorMessage = error.localizedDescription permissionsRequestState = .shouldRequest + DiagnosticLogger.log("HealthKit unavailable: \(error.localizedDescription)") } // Listen for preference changes to re-filter data immediately @@ -96,6 +108,7 @@ class HealthKitManager: ObservableObject { } func fetchSleepData() async throws { + DiagnosticLogger.log("fetchSleepData — state=\(permissionsRequestState)") defer { if permissionsRequestState == .loading { permissionsRequestState = .shouldRequest @@ -106,19 +119,89 @@ class HealthKitManager: ObservableObject { try await requestAuthorization() } catch { errorMessage = error.localizedDescription + DiagnosticLogger.log("fetchSleepData authorization failed: \(error.localizedDescription)") throw error } do { try await loadSleepData() startObservingSleepChanges() + logLoadResults(context: "fetchSleepData") } catch is CancellationError { // A newer refresh superseded this one; its results (or error) stand. + DiagnosticLogger.log("fetchSleepData superseded by a newer refresh") } catch { errorMessage = "Failed to fetch sleep data: \(error.localizedDescription)" + DiagnosticLogger.log("fetchSleepData load failed: \(error.localizedDescription)") throw error } } + + /// Backs the "Grant Access" button and the Settings retry. + /// + /// Unlike `requestAuthorization()`, this always re-presents the HealthKit prompt + /// rather than short-circuiting once the app has asked before. HealthKit silently + /// no-ops the prompt when the user already answered, so the button would otherwise + /// appear to do nothing; reloading afterwards is what actually surfaces whether + /// read access was granted. + func requestAccessFromUser() async { + guard !isRequestingAccess else { + DiagnosticLogger.log("requestAccessFromUser ignored — already in progress") + return + } + + isRequestingAccess = true + errorMessage = nil + DiagnosticLogger.log("User tapped Grant Access — state=\(permissionsRequestState)") + 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() + startObservingSleepChanges() + logLoadResults(context: "after user grant") + + if rawSleepSamples.isEmpty { + errorMessage = Self.noDataAfterGrantMessage + } + } catch is CancellationError { + DiagnosticLogger.log("requestAccessFromUser superseded by a newer refresh") + } catch { + permissionsRequestState = .shouldRequest + errorMessage = error.localizedDescription + DiagnosticLogger.log("requestAccessFromUser failed: \(error.localizedDescription)") + } + } + + /// HealthKit reports no error when read access is denied — queries just come back + /// empty — so an empty result right after granting is the only signal we can offer. + private static let noDataAfterGrantMessage = "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)" + ) + } /// Loads sleep data, keeping only the newest request's results. /// @@ -225,7 +308,14 @@ class HealthKitManager: ObservableObject { sortDescriptors: [SortDescriptor(\.startDate, order: .reverse)] ) - return try await descriptor.result(for: healthStore) + do { + let samples = try await descriptor.result(for: healthStore) + DiagnosticLogger.log("Sleep sample query returned \(samples.count) samples") + return samples + } catch { + DiagnosticLogger.log("Sleep sample query failed: \(error.localizedDescription)") + throw error + } } /// Loads every source that has ever written sleep data for the Settings filter. @@ -238,10 +328,13 @@ class HealthKitManager: ObservableObject { ) do { - availableSources = try await descriptor.result(for: healthStore) + let sources = try await descriptor.result(for: healthStore) .sorted { $0.name < $1.name } + availableSources = sources + DiagnosticLogger.log("Discovered \(sources.count) sleep sources: \(sources.map(\.name).joined(separator: ", "))") } catch { errorMessage = "Failed to discover sleep sources: \(error.localizedDescription)" + DiagnosticLogger.log("Source discovery failed: \(error.localizedDescription)") } } @@ -257,6 +350,11 @@ class HealthKitManager: ObservableObject { sourcePreferences.isSourceSelected($0.source.source.bundleIdentifier) } self.sleepSessions = Dictionary(grouping: includedSessions) { $0.dateForGrouping } + + DiagnosticLogger.log( + "Processed \(samples.count) samples → \(allSessions.count) sessions, " + + "\(includedSessions.count) after source filtering" + ) } #if DEBUG diff --git a/Bedtime/Bedtime/Utils/DiagnosticLogger.swift b/Bedtime/Bedtime/Utils/DiagnosticLogger.swift new file mode 100644 index 0000000..dc046f6 --- /dev/null +++ b/Bedtime/Bedtime/Utils/DiagnosticLogger.swift @@ -0,0 +1,74 @@ +// +// DiagnosticLogger.swift +// Bedtime +// + +import Combine +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.). +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() {} + + /// 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.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 ?? "?" + + 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 + } + + @MainActor + func clear() { + entries.removeAll() + Self.log("Log cleared") + } + + @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 { + entries.removeFirst(entries.count - maxEntries) + } + print(entry) + } +} diff --git a/Bedtime/Bedtime/Views/Components/ShareSheet.swift b/Bedtime/Bedtime/Views/Components/ShareSheet.swift new file mode 100644 index 0000000..ae0ca62 --- /dev/null +++ b/Bedtime/Bedtime/Views/Components/ShareSheet.swift @@ -0,0 +1,50 @@ +// +// ShareSheet.swift +// Bedtime +// + +import UIKit + +/// 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 } + + 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) + } + + 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/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 e6f46bf..683da85 100644 --- a/Bedtime/Bedtime/Views/LastNightCard.swift +++ b/Bedtime/Bedtime/Views/LastNightCard.swift @@ -35,7 +35,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 b181d33..c7123ff 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 @@ -29,6 +30,11 @@ struct SettingsView: View { @State private var debugMessage: String? #endif + @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 } @@ -172,6 +178,66 @@ 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 { + SharePresenter.present(items: [diagnosticLogger.exportText()]) + } 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) + } + #if DEBUG Section("Developer") { Button { @@ -239,6 +305,17 @@ struct SettingsView: View { } } + private func retryHealthKitAccess() { + isRetryingHealthKit = true + diagnosticsMessage = nil + Task { @MainActor in + await healthKitManager.requestAccessFromUser() + await healthKitManager.loadAvailableSources() + 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.