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)
],
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
) {
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

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


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
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,
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),
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)
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

}
}
}
Loading