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
9 changes: 7 additions & 2 deletions Bedtime/Bedtime/ContentView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -88,8 +88,13 @@ struct ContentView: View {
}

// Recent Sleep Sessions
if !healthKitManager.sleepSessions.isEmpty {
RecentSleepSessionsCard(sessions: healthKitManager.sleepSessions, sleepGoal: userPreferences.sleepGoalHours)
if !healthKitManager.sleepSessions.isEmpty || !healthKitManager.allSleepSessions.isEmpty {
RecentSleepSessionsCard(
sessions: healthKitManager.sleepSessions,
allSessions: healthKitManager.allSleepSessions,
excludedSourceIDs: sourcePreferences.excludedBundleIdentifiers,
sleepGoal: userPreferences.sleepGoalHours
)
}
}
}
Expand Down
15 changes: 8 additions & 7 deletions Bedtime/Bedtime/Models/HealthKitManager.swift
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,8 @@ class HealthKitManager: ObservableObject {

@Published private(set) var permissionsRequestState: PermissionsRequestState = .loading
@Published var sleepSessions: [Date: [SleepSession]] = [:]
/// All sessions regardless of source preferences — used for per-source comparison UI.
@Published private(set) var allSleepSessions: [Date: [SleepSession]] = [:]
@Published var errorMessage: String?
@Published var availableSources: [HKSource]?

Expand Down Expand Up @@ -183,14 +185,13 @@ 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) }
let allSessions = samples.compactMap { SleepSession(sample: $0) }
self.allSleepSessions = Dictionary(grouping: allSessions) { $0.dateForGrouping }

self.sleepSessions = Dictionary(grouping: sessions) { $0.dateForGrouping }
let includedSessions = allSessions.filter {
sourcePreferences.isSourceSelected($0.source.source.bundleIdentifier)
}
self.sleepSessions = Dictionary(grouping: includedSessions) { $0.dateForGrouping }
}

#if DEBUG
Expand Down
7 changes: 7 additions & 0 deletions Bedtime/Bedtime/Models/SleepData.swift
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,13 @@ extension SleepSession {

extension HKCategoryValueSleepAnalysis {

static let allAsleepValues: [HKCategoryValueSleepAnalysis] = [
.asleepUnspecified,
.asleepCore,
.asleepDeep,
.asleepREM,
]

var displayName: String {
switch self {
case .asleepUnspecified: return "Asleep"
Expand Down
8 changes: 8 additions & 0 deletions Bedtime/Bedtime/Views/Components/SleepDayGroup.swift
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,8 @@ import SwiftUI
struct SleepDayGroup: View {
let date: Date
let sessions: [SleepSession]
let allSessions: [SleepSession]
let excludedSourceIDs: Set<String>
let isExpanded: Bool
let sleepGoal: Double
let onToggle: () -> Void
Expand Down Expand Up @@ -91,6 +93,12 @@ struct SleepDayGroup: View {
.buttonStyle(PlainButtonStyle())
.disabled(!hasSessions)

SleepSourceComparisonView(
sessions: allSessions,
excludedSourceIDs: excludedSourceIDs
)
.padding(.leading, 4)

// Session details (expandable)
if isExpanded && hasSessions {
VStack(spacing: 4) {
Expand Down
132 changes: 132 additions & 0 deletions Bedtime/Bedtime/Views/Components/SleepSourceComparisonView.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,132 @@
//
// SleepSourceComparisonView.swift
// Bedtime
//

import SwiftUI
import HealthKit

/// Aligned per-source sleep stage timelines for quick cross-source comparison.
struct SleepSourceComparisonView: View {
let sessions: [SleepSession]
let excludedSourceIDs: Set<String>

private struct SourceTrack: Identifiable {
let id: String
let name: String
let sessions: [SleepSession]
let isEnabled: Bool

var totalDuration: TimeInterval {
sessions.reduce(0) { $0 + $1.duration }
}
}

private var sourceTracks: [SourceTrack] {
let grouped = Dictionary(grouping: sessions) { $0.source.source.bundleIdentifier }
return grouped
.map { bundleID, sessions in
SourceTrack(
id: bundleID,
name: sessions.first?.source.source.name ?? bundleID,
sessions: sessions.sorted { $0.startDate < $1.startDate },
isEnabled: !excludedSourceIDs.contains(bundleID)
)
}
.sorted { $0.name.localizedCaseInsensitiveCompare($1.name) == .orderedAscending }
}

private var timeRange: (start: Date, end: Date)? {
guard let start = sessions.map(\.startDate).min(),
let end = sessions.map(\.endDate).max(),
end > start else { return nil }
return (start, end)
}

private var shouldShow: Bool {
!sourceTracks.isEmpty && (
sourceTracks.count > 1 ||
sourceTracks.contains { !$0.isEnabled }
)
}

var body: some View {
if let timeRange, shouldShow {
VStack(alignment: .leading, spacing: 4) {
ForEach(sourceTracks) { track in
HStack(spacing: 6) {
Text(track.name)
.font(.caption2)
.foregroundStyle(track.isEnabled ? .secondary : .tertiary)
.lineLimit(1)
.frame(width: 72, alignment: .leading)

SleepStageTimelineBar(
sessions: track.sessions,
rangeStart: timeRange.start,
rangeEnd: timeRange.end,
isDimmed: !track.isEnabled
)

Text(TimeFormatter.formatDuration(track.totalDuration))
.font(.caption2)
.monospacedDigit()
.foregroundStyle(track.isEnabled ? .secondary : .tertiary)
.frame(width: 36, alignment: .trailing)
}
}
}
.padding(.bottom, 6)
}
}
}

/// A single horizontal bar with color-coded sleep stage segments.
struct SleepStageTimelineBar: View {
let sessions: [SleepSession]
let rangeStart: Date
let rangeEnd: Date
var isDimmed: Bool = false

private var rangeDuration: TimeInterval {
max(rangeEnd.timeIntervalSince(rangeStart), 1)
}

var body: some View {
Capsule()
.fill(.foreground.quaternary)
.overlay(alignment: .leading) {
GeometryReader { proxy in
ForEach(Array(sessions.enumerated()), id: \.offset) { _, session in
let offset = session.startDate.timeIntervalSince(rangeStart) / rangeDuration
let width = session.duration / rangeDuration

Capsule()
.fill(session.sleepType.color)
.opacity(isDimmed ? 0.35 : 1)
.frame(width: max(proxy.size.width * width, 1))
.offset(x: proxy.size.width * offset)
}
}
}
.clipShape(Capsule())
.frame(height: 6)
}
}

#Preview(traits: .sizeThatFitsLayout) {
let sourceA = HKSourceRevision(source: .default(), version: "1")
let sourceB = HKSourceRevision(source: .default(), version: "2")
let bedTime = Calendar.current.date(bySettingHour: 23, minute: 0, second: 0, of: Date())!

SleepSourceComparisonView(
sessions: [
SleepSession(startDate: bedTime, endDate: bedTime.addingTimeInterval(3600), sleepType: .asleepCore, source: sourceA),
SleepSession(startDate: bedTime.addingTimeInterval(3600), endDate: bedTime.addingTimeInterval(7200), sleepType: .asleepDeep, source: sourceA),
SleepSession(startDate: bedTime.addingTimeInterval(300), endDate: bedTime.addingTimeInterval(5400), sleepType: .asleepCore, source: sourceB),
SleepSession(startDate: bedTime.addingTimeInterval(5400), endDate: bedTime.addingTimeInterval(7800), sleepType: .asleepREM, source: sourceB),
],
excludedSourceIDs: ["com.example.disabled"]
)
.padding()
}
20 changes: 15 additions & 5 deletions Bedtime/Bedtime/Views/RecentSleepSessionsCard.swift
Original file line number Diff line number Diff line change
Expand Up @@ -9,20 +9,28 @@ import SwiftUI

struct RecentSleepSessionsCard: View {

init(sessions: [Date: [SleepSession]], sleepGoal: Double, dayCount: Int = Constants.sleepHistoryDays) {
init(
sessions: [Date: [SleepSession]],
allSessions: [Date: [SleepSession]],
excludedSourceIDs: Set<String>,
sleepGoal: Double,
dayCount: Int = Constants.sleepHistoryDays
) {
self.sleepGoal = sleepGoal
self.excludedSourceIDs = excludedSourceIDs

let calendar = Calendar.current
let today = calendar.startOfDay(for: Date())
self.sortedSessions = (0..<dayCount).compactMap { offset -> (Date, [SleepSession])? in
self.sortedSessions = (0..<dayCount).compactMap { offset -> (Date, [SleepSession], [SleepSession])? in
guard let day = calendar.date(byAdding: .day, value: -offset, to: today) else { return nil }
let dayStart = calendar.startOfDay(for: day)
return (dayStart, sessions[dayStart] ?? [])
return (dayStart, sessions[dayStart] ?? [], allSessions[dayStart] ?? [])
}
}

let sortedSessions: [(Date, [SleepSession])]
let sortedSessions: [(Date, [SleepSession], [SleepSession])]
let sleepGoal: Double
let excludedSourceIDs: Set<String>

@State private var expandedNights: Set<Date> = []

Expand All @@ -38,10 +46,12 @@ struct RecentSleepSessionsCard: View {

// Grouped sleep sessions
VStack(spacing: 8) {
ForEach(sortedSessions, id: \.0) { night, nightSessions in
ForEach(sortedSessions, id: \.0) { night, nightSessions, allNightSessions in
SleepDayGroup(
date: night,
sessions: nightSessions,
allSessions: allNightSessions,
excludedSourceIDs: excludedSourceIDs,
isExpanded: expandedNights.contains(night),
sleepGoal: sleepGoal,
onToggle: {
Expand Down