Skip to content
8 changes: 4 additions & 4 deletions Package.swift
Original file line number Diff line number Diff line change
Expand Up @@ -6,10 +6,10 @@ import PackageDescription
let package = Package(
name: "SleepChartKit",
platforms: [
.iOS(.v15),
.macOS(.v12),
.watchOS(.v8),
.tvOS(.v15)
.iOS(.v16),
.macOS(.v13),
.watchOS(.v9),
.tvOS(.v16)
Comment on lines +9 to +12
Copy link

Copilot AI Feb 2, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This PR bumps the minimum deployment targets (e.g., iOS 15 → 16, macOS 12 → 13). That’s a breaking change for package consumers; please confirm it’s required (e.g., for ViewThatFits) and call it out explicitly in the PR description / release notes so downstream apps can plan accordingly.

Suggested change
.iOS(.v16),
.macOS(.v13),
.watchOS(.v9),
.tvOS(.v16)
.iOS(.v15),
.macOS(.v12),
.watchOS(.v8),
.tvOS(.v15)

Copilot uses AI. Check for mistakes.
],
products: [
.library(
Expand Down
7 changes: 5 additions & 2 deletions Sources/SleepChartKit/Components/SleepChartView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -122,10 +122,12 @@ public struct SleepChartView: View {

public var body: some View {
switch style {
case .timeline:
case .timeline, .timelineNoDurations:
timelineChartView

case .circular:
circularChartView

case .minimal:
minimalChartView
}
Expand Down Expand Up @@ -153,7 +155,8 @@ public struct SleepChartView: View {
sleepData: sleepData,
colorProvider: colorProvider,
durationFormatter: durationFormatter,
displayNameProvider: displayNameProvider
displayNameProvider: displayNameProvider,
hideDurations: style == .timelineNoDurations
)
.padding(.top, SleepChartConstants.legendTopPadding)
}
Expand Down
18 changes: 16 additions & 2 deletions Sources/SleepChartKit/Components/SleepCircularChartView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -68,7 +68,7 @@ public struct SleepCircularChartView: View {
backgroundColor: Color = .clear,
showLabels: Bool = true,
showIcons: Bool = true,
thresholdHours: Double = 9.0
thresholdHours: Double = 12
) {
Comment on lines 69 to 72
Copy link

Copilot AI Feb 2, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The default thresholdHours value was changed to 12, but the stored property comment and initializer docs still say the default is 9 hours. Please update the documentation to match the new default (and/or explain why 12 is now the default).

Copilot uses AI. Check for mistakes.
self.samples = samples
self.colorProvider = colorProvider
Expand Down Expand Up @@ -104,6 +104,20 @@ public struct SleepCircularChartView: View {
var segments: [SleepSegment] = []
var currentAngle: Double = -90 // Start at top (12 o'clock)

//making the chart look like the sleep starts at it would on a clock
let sleepStart = samples.first!.startDate

Comment on lines +107 to +109
Copy link

Copilot AI Feb 2, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In sleepSegments, samples.first! is force-unwrapped even though the method already guards against an empty array. Consider using the already-validated first sample from the guard (or guard let sleepStart = samples.first?.startDate else { ... }) to avoid unnecessary force unwraps and keep the control flow clearer.

Copilot uses AI. Check for mistakes.
var h: Double = Double(Calendar.current.component(.hour, from: sleepStart))
if h >= 12 { h -= 12 }

let m: Double = Double(Calendar.current.component(.minute, from: sleepStart))

var difference: Double = (360 / 12) * h
difference += (360 / 12) * (m / 60)

currentAngle += difference
Comment on lines +107 to +118
Copy link

Copilot AI Feb 2, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The new “rotate chart based on sleep start time” logic changes the circular chart’s core positioning behavior, but there’s no unit coverage validating the resulting start angle (existing tests only assert the view can be created). Consider extracting the angle/segment calculation into a testable helper (internal function/type) and adding tests for representative start times (e.g., 23:00 → near 11 o’clock, 07:00 → near 7 o’clock).

Copilot uses AI. Check for mistakes.


for sample in samples {
let samplePercentage = sample.duration / totalDuration
let sampleArcDegrees = samplePercentage * totalArcDegrees
Expand Down Expand Up @@ -199,7 +213,7 @@ public struct SleepCircularChartView: View {
let symbolOffset = innerRingRadius

// Moon symbol at start of sleep arc
Image(systemName: "moon.fill")
Image(systemName: "bed.double.fill")
.foregroundColor(.white)
.font(.caption2)
.offset(
Expand Down
94 changes: 72 additions & 22 deletions Sources/SleepChartKit/Components/SleepLegendView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -6,68 +6,102 @@ public struct SleepLegendView: View {
private let colorProvider: SleepStageColorProvider
private let durationFormatter: DurationFormatter
private let displayNameProvider: SleepStageDisplayNameProvider
private let hideDurations: Bool

public init(
activeStages: [SleepStage],
sleepData: [SleepStage: TimeInterval],
colorProvider: SleepStageColorProvider = DefaultSleepStageColorProvider(),
durationFormatter: DurationFormatter = DefaultDurationFormatter(),
displayNameProvider: SleepStageDisplayNameProvider = DefaultSleepStageDisplayNameProvider()
displayNameProvider: SleepStageDisplayNameProvider = DefaultSleepStageDisplayNameProvider(),
hideDurations: Bool = false
) {
self.activeStages = activeStages
self.sleepData = sleepData
self.colorProvider = colorProvider
self.durationFormatter = durationFormatter
self.displayNameProvider = displayNameProvider
self.hideDurations = hideDurations
}

// MARK: - Layout Configuration

/// Grid configuration for legend items with adaptive sizing
private var columns: [GridItem] {
[GridItem(.adaptive(
minimum: SleepChartConstants.legendItemMinWidth,
maximum: SleepChartConstants.legendItemMaxWidth
))]
private var oneRow: [GridItem] {
[
GridItem(.adaptive(minimum: SleepChartConstants.legendItemMinWidth,
maximum: SleepChartConstants.legendItemMaxWidth)
)
]
}

private var twoRows: [GridItem] {
[
GridItem(.adaptive(minimum: SleepChartConstants.legendItemMinWidth,
maximum: SleepChartConstants.legendItemMaxWidth), spacing: 0, alignment: .leading),

GridItem(.adaptive(minimum: SleepChartConstants.legendItemMinWidth,
maximum: SleepChartConstants.legendItemMaxWidth), spacing: 0, alignment: .leading)

]
}

// MARK: - Body

public var body: some View {
LazyVGrid(
columns: columns,
alignment: .leading,
spacing: SleepChartConstants.legendItemSpacing
) {

ViewThatFits {

LazyHGrid(
rows: oneRow,
alignment: .center,
spacing: SleepChartConstants.legendItemSpacing
) {
legendItems
}

LazyHGrid(
rows: twoRows,
alignment: .center,
spacing: SleepChartConstants.legendItemSpacing
) {
legendItems
}
}
}

// MARK: - Legend Items

@ViewBuilder
private var legendItems: some View {
ForEach(activeStages, id: \.self) { stage in
// Only show stages that have recorded time
if let duration = sleepData[stage], duration > 0 {
LegendItem(
stage: stage,
duration: duration,
duration: hideDurations ? nil : duration,
colorProvider: colorProvider,
durationFormatter: durationFormatter,
displayNameProvider: displayNameProvider
)
}
}
}
}
}

/// A single legend item displaying a sleep stage with its color, name, and duration.
///
/// This view shows a colored circle indicator, the stage name, and formatted duration
/// in a horizontal layout suitable for use in a legend grid.
private struct LegendItem: View {
public struct LegendItem: View {

// MARK: - Properties

/// The sleep stage this item represents
let stage: SleepStage

/// The total duration for this sleep stage
let duration: TimeInterval
let duration: TimeInterval?

/// Provider for the stage color
let colorProvider: SleepStageColorProvider
Expand All @@ -78,10 +112,23 @@ private struct LegendItem: View {
/// Provider for the stage display name
let displayNameProvider: SleepStageDisplayNameProvider

public init(stage: SleepStage,
duration: TimeInterval?,
colorProvider: SleepStageColorProvider = DefaultSleepStageColorProvider(),
durationFormatter: DurationFormatter = DefaultDurationFormatter(),
displayNameProvider: SleepStageDisplayNameProvider = DefaultSleepStageDisplayNameProvider()) {

self.stage = stage
self.duration = duration
self.colorProvider = colorProvider
self.durationFormatter = durationFormatter
self.displayNameProvider = displayNameProvider
}

// MARK: - Body

var body: some View {
HStack(spacing: SleepChartConstants.legendItemSpacing) {
public var body: some View {
HStack(spacing: 4) {
// Color indicator circle
Comment on lines +130 to 132
Copy link

Copilot AI Feb 2, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LegendItem uses a hard-coded HStack(spacing: 4) while overall legend spacing is now controlled by SleepChartConstants.legendItemSpacing. Consider introducing a dedicated constant for “within-item” spacing (or reusing an existing one) instead of a magic number so layout tuning remains centralized.

Copilot uses AI. Check for mistakes.
Circle()
.fill(colorProvider.color(for: stage))
Expand All @@ -95,10 +142,13 @@ private struct LegendItem: View {
.font(.caption)
.foregroundColor(.secondary)

// Duration
Text(durationFormatter.format(duration))
.font(.caption.weight(.semibold))
.foregroundColor(.primary)
if let duration {

// Duration
Text(durationFormatter.format(duration))
.font(.caption.weight(.semibold))
.foregroundColor(.primary)
}
}
}
}
}
27 changes: 21 additions & 6 deletions Sources/SleepChartKit/Components/SleepTimelineGraph.swift
Original file line number Diff line number Diff line change
Expand Up @@ -137,7 +137,9 @@ public struct SleepTimelineGraph: View {
renderStageConnector(
context: context,
from: prevRect,
to: currentRect
to: currentRect,
fromStage: previousStage,
Copy link

Copilot AI Feb 2, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In the connector rendering call, previousStage is already unwrapped into prevStage, but the code still passes previousStage (optional) into fromStage:. This makes the API/typing noisier than necessary. Consider passing prevStage and changing renderStageConnector to take non-optional SleepStage parameters (since the connector is only drawn when both stages exist).

Suggested change
fromStage: previousStage,
fromStage: prevStage,

Copilot uses AI. Check for mistakes.
toStage: currentStage
)
}

Expand All @@ -159,10 +161,12 @@ public struct SleepTimelineGraph: View {
private func renderStageConnector(
context: GraphicsContext,
from startRect: CGRect,
to endRect: CGRect
to endRect: CGRect,
fromStage: SleepStage?,
toStage: SleepStage?
) {
let startPoint = CGPoint(x: startRect.maxX, y: startRect.midY)
let endPoint = CGPoint(x: endRect.minX, y: endRect.midY)
let startPoint = CGPoint(x: startRect.maxX - SleepChartConstants.connectorOffset, y: startRect.midY)
let endPoint = CGPoint(x: endRect.minX + SleepChartConstants.connectorOffset, y: endRect.midY)

// Calculate control points for smooth Bézier curve
let controlPoint1 = CGPoint(
Expand All @@ -179,10 +183,21 @@ public struct SleepTimelineGraph: View {
connectorPath.move(to: startPoint)
connectorPath.addCurve(to: endPoint, control1: controlPoint1, control2: controlPoint2)

var gradient = Gradient(stops: [.init(color: .blue, location: 0), .init(color: .red, location: 1)])

if let fromStage,
let toStage {

let start = colorProvider.color(for: fromStage).opacity(SleepChartConstants.connectorOpacity)
let end = colorProvider.color(for: toStage).opacity(SleepChartConstants.connectorOpacity)

gradient = Gradient(stops: [.init(color: start, location: 0), .init(color: end, location: 1)])
}

context.stroke(
connectorPath,
with: .color(.gray.opacity(SleepChartConstants.connectorOpacity)),
with: .linearGradient(gradient, startPoint: controlPoint1, endPoint: controlPoint2),
Copy link

Copilot AI Feb 2, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The linear gradient for the connector stroke uses controlPoint1/controlPoint2 as the gradient’s start/end points. That makes the gradient direction dependent on curve control points rather than the actual connector endpoints, which can look incorrect when stage durations/positions vary. Consider using startPoint and endPoint for the gradient vector so the color transition aligns with the connector’s start/end stages.

Suggested change
with: .linearGradient(gradient, startPoint: controlPoint1, endPoint: controlPoint2),
with: .linearGradient(gradient, startPoint: startPoint, endPoint: endPoint),

Copilot uses AI. Check for mistakes.
lineWidth: SleepChartConstants.connectorLineWidth
)
}
}
}
3 changes: 3 additions & 0 deletions Sources/SleepChartKit/Models/SleepChartStyle.swift
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,9 @@ public enum SleepChartStyle {

/// Minimal timeline chart without axis, legends, or overlays
case minimal

/// Timeline with no legend
case timelineNoDurations
}

/// Configuration options for circular sleep charts
Expand Down
4 changes: 2 additions & 2 deletions Sources/SleepChartKit/Models/SleepSample.swift
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import Foundation
import HealthKit
#endif

public struct SleepSample: Hashable {
public struct SleepSample: Hashable, Codable {
public let stage: SleepStage
public let startDate: Date
public let endDate: Date
Expand Down Expand Up @@ -36,4 +36,4 @@ public struct SleepSample: Hashable {
public var duration: TimeInterval {
endDate.timeIntervalSince(startDate)
}
}
}
2 changes: 1 addition & 1 deletion Sources/SleepChartKit/Models/SleepStage.swift
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import Foundation
import HealthKit
#endif

public enum SleepStage: Int, CaseIterable, Hashable {
public enum SleepStage: Int, CaseIterable, Hashable, Codable {
case awake = 0
case asleepREM = 1
case asleepCore = 2
Expand Down
38 changes: 36 additions & 2 deletions Sources/SleepChartKit/Services/SleepStageColorProvider.swift
Original file line number Diff line number Diff line change
Expand Up @@ -38,10 +38,44 @@ public struct DefaultSleepStageColorProvider: SleepStageColorProvider {
case .asleepDeep:
return .indigo
case .asleepUnspecified:
return .purple
return Color(UIColor.lightGray)
Copy link

Copilot AI Feb 2, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

DefaultSleepStageColorProvider uses UIColor.lightGray, but this package declares macOS/watchOS support where UIColor isn’t available. This will fail to compile on non-UIKit platforms; use a cross-platform Color (e.g., .gray/.secondary) or wrap in #if canImport(UIKit)/#elseif canImport(AppKit) with UIColor/NSColor as appropriate.

Suggested change
return Color(UIColor.lightGray)
return .gray

Copilot uses AI. Check for mistakes.
case .inBed:
return .gray
}
}

}
}

public struct CustomSleepStageColorProvider: SleepStageColorProvider {

var awakeColour: Color
var remColour: Color
var coreColour: Color
var deepColour: Color
var unspecifiedColour: Color
var inBedColour: Color

public init(awake: Color? = nil, REM: Color? = nil, core: Color? = nil, deep: Color? = nil, unspecified: Color? = nil, inBed: Color? = nil) {

self.awakeColour = awake ?? .orange
self.remColour = REM ?? .cyan
self.coreColour = core ?? .blue
self.deepColour = deep ?? .indigo
self.unspecifiedColour = unspecified ?? .purple
self.inBedColour = inBed ?? .gray
}

public func color(for stage: SleepStage) -> Color {

switch stage {

case .awake: return awakeColour
case .asleepREM: return remColour
case .asleepCore: return coreColour
case .asleepDeep: return deepColour
case .asleepUnspecified: return unspecifiedColour
case .inBed: return inBedColour
Comment on lines +51 to +77
Copy link

Copilot AI Feb 2, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

CustomSleepStageColorProvider exposes a public API with inconsistent naming (awakeColour/remColour vs the rest of the codebase’s Color terminology) and a parameter label REM that breaks Swift API design guidelines (capitalized acronym as an external label is unusual). Consider renaming to awakeColor, remColor, etc., and using a rem: parameter label for consistency and discoverability.

Suggested change
var awakeColour: Color
var remColour: Color
var coreColour: Color
var deepColour: Color
var unspecifiedColour: Color
var inBedColour: Color
public init(awake: Color? = nil, REM: Color? = nil, core: Color? = nil, deep: Color? = nil, unspecified: Color? = nil, inBed: Color? = nil) {
self.awakeColour = awake ?? .orange
self.remColour = REM ?? .cyan
self.coreColour = core ?? .blue
self.deepColour = deep ?? .indigo
self.unspecifiedColour = unspecified ?? .purple
self.inBedColour = inBed ?? .gray
}
public func color(for stage: SleepStage) -> Color {
switch stage {
case .awake: return awakeColour
case .asleepREM: return remColour
case .asleepCore: return coreColour
case .asleepDeep: return deepColour
case .asleepUnspecified: return unspecifiedColour
case .inBed: return inBedColour
var awakeColor: Color
var remColor: Color
var coreColor: Color
var deepColor: Color
var unspecifiedColor: Color
var inBedColor: Color
public init(awake: Color? = nil, rem: Color? = nil, core: Color? = nil, deep: Color? = nil, unspecified: Color? = nil, inBed: Color? = nil) {
self.awakeColor = awake ?? .orange
self.remColor = rem ?? .cyan
self.coreColor = core ?? .blue
self.deepColor = deep ?? .indigo
self.unspecifiedColor = unspecified ?? .purple
self.inBedColor = inBed ?? .gray
}
@available(*, deprecated, message: "Use init(awake:rem:core:deep:unspecified:inBed:) instead.")
public init(awake: Color? = nil, REM: Color? = nil, core: Color? = nil, deep: Color? = nil, unspecified: Color? = nil, inBed: Color? = nil) {
self.init(
awake: awake,
rem: REM,
core: core,
deep: deep,
unspecified: unspecified,
inBed: inBed
)
}
public func color(for stage: SleepStage) -> Color {
switch stage {
case .awake: return awakeColor
case .asleepREM: return remColor
case .asleepCore: return coreColor
case .asleepDeep: return deepColor
case .asleepUnspecified: return unspecifiedColor
case .inBed: return inBedColor

Copilot uses AI. Check for mistakes.

}
}
}
Loading