Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 11 additions & 2 deletions Bedtime/Bedtime/BedtimeApp.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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"] {
Expand All @@ -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)")
}
}
Expand All @@ -44,6 +50,9 @@ struct BedtimeApp: App {
var body: some Scene {
WindowGroup {
ContentView()
.onAppear {
DiagnosticLogger.log("App window appeared")
}
}
.modelContainer(sharedModelContainer)
}
Expand Down
3 changes: 3 additions & 0 deletions Bedtime/Bedtime/ContentView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)")
}
}
}

Expand Down
104 changes: 101 additions & 3 deletions Bedtime/Bedtime/Models/HealthKitManager.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -39,6 +47,8 @@ class HealthKitManager: ObservableObject {
private var currentLoad: Task<Void, Error>?

@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]] = [:]
Expand All @@ -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
Expand Down Expand Up @@ -96,6 +108,7 @@ class HealthKitManager: ObservableObject {
}

func fetchSleepData() async throws {
DiagnosticLogger.log("fetchSleepData — state=\(permissionsRequestState)")
defer {
if permissionsRequestState == .loading {
permissionsRequestState = .shouldRequest
Expand All @@ -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.
///
Expand Down Expand Up @@ -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.
Expand All @@ -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)")
}
}

Expand All @@ -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
Expand Down
74 changes: 74 additions & 0 deletions Bedtime/Bedtime/Utils/DiagnosticLogger.swift
Original file line number Diff line number Diff line change
@@ -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)
}
}
50 changes: 50 additions & 0 deletions Bedtime/Bedtime/Views/Components/ShareSheet.swift
Original file line number Diff line number Diff line change
@@ -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
}
}
13 changes: 11 additions & 2 deletions Bedtime/Bedtime/Views/HealthKitAuthorizationCard.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
}
}
Expand Down
2 changes: 1 addition & 1 deletion Bedtime/Bedtime/Views/LastNightCard.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down
Loading