diff --git a/Package.swift b/Package.swift index 99877d4..5d9b6f7 100644 --- a/Package.swift +++ b/Package.swift @@ -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( diff --git a/Sources/SleepChartKit/Components/SleepChartView.swift b/Sources/SleepChartKit/Components/SleepChartView.swift index 89abdc9..ad19a91 100644 --- a/Sources/SleepChartKit/Components/SleepChartView.swift +++ b/Sources/SleepChartKit/Components/SleepChartView.swift @@ -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 } @@ -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) } diff --git a/Sources/SleepChartKit/Components/SleepCircularChartView.swift b/Sources/SleepChartKit/Components/SleepCircularChartView.swift index c5bbef4..bdf5cba 100644 --- a/Sources/SleepChartKit/Components/SleepCircularChartView.swift +++ b/Sources/SleepChartKit/Components/SleepCircularChartView.swift @@ -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 @@ -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 @@ -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( diff --git a/Sources/SleepChartKit/Components/SleepLegendView.swift b/Sources/SleepChartKit/Components/SleepLegendView.swift index 688cc1e..38c2b4f 100644 --- a/Sources/SleepChartKit/Components/SleepLegendView.swift +++ b/Sources/SleepChartKit/Components/SleepLegendView.swift @@ -6,45 +6,80 @@ 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 @@ -52,14 +87,13 @@ public struct SleepLegendView: View { } } } - } } /// 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 @@ -67,7 +101,7 @@ private struct LegendItem: View { let stage: SleepStage /// The total duration for this sleep stage - let duration: TimeInterval + let duration: TimeInterval? /// Provider for the stage color let colorProvider: SleepStageColorProvider @@ -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)) @@ -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) + } } } -} \ No newline at end of file +} diff --git a/Sources/SleepChartKit/Components/SleepTimelineGraph.swift b/Sources/SleepChartKit/Components/SleepTimelineGraph.swift index 381081d..0078cc2 100644 --- a/Sources/SleepChartKit/Components/SleepTimelineGraph.swift +++ b/Sources/SleepChartKit/Components/SleepTimelineGraph.swift @@ -137,7 +137,9 @@ public struct SleepTimelineGraph: View { renderStageConnector( context: context, from: prevRect, - to: currentRect + to: currentRect, + fromStage: previousStage, + toStage: currentStage ) } @@ -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( @@ -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 ) } -} \ No newline at end of file +} diff --git a/Sources/SleepChartKit/Models/SleepChartStyle.swift b/Sources/SleepChartKit/Models/SleepChartStyle.swift index 39287db..47a474c 100644 --- a/Sources/SleepChartKit/Models/SleepChartStyle.swift +++ b/Sources/SleepChartKit/Models/SleepChartStyle.swift @@ -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 diff --git a/Sources/SleepChartKit/Models/SleepSample.swift b/Sources/SleepChartKit/Models/SleepSample.swift index 462133c..4a3e3c9 100644 --- a/Sources/SleepChartKit/Models/SleepSample.swift +++ b/Sources/SleepChartKit/Models/SleepSample.swift @@ -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 @@ -36,4 +36,4 @@ public struct SleepSample: Hashable { public var duration: TimeInterval { endDate.timeIntervalSince(startDate) } -} \ No newline at end of file +} diff --git a/Sources/SleepChartKit/Models/SleepStage.swift b/Sources/SleepChartKit/Models/SleepStage.swift index 0ff5fa3..7822823 100644 --- a/Sources/SleepChartKit/Models/SleepStage.swift +++ b/Sources/SleepChartKit/Models/SleepStage.swift @@ -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 diff --git a/Sources/SleepChartKit/Services/SleepStageColorProvider.swift b/Sources/SleepChartKit/Services/SleepStageColorProvider.swift index f0450f6..a03deef 100644 --- a/Sources/SleepChartKit/Services/SleepStageColorProvider.swift +++ b/Sources/SleepChartKit/Services/SleepStageColorProvider.swift @@ -38,10 +38,44 @@ public struct DefaultSleepStageColorProvider: SleepStageColorProvider { case .asleepDeep: return .indigo case .asleepUnspecified: - return .purple + return Color(UIColor.lightGray) case .inBed: return .gray } } -} \ No newline at end of file +} + +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 + + } + } +} diff --git a/Sources/SleepChartKit/Utils/SleepChartConstants.swift b/Sources/SleepChartKit/Utils/SleepChartConstants.swift index e1652ea..230fdb0 100644 --- a/Sources/SleepChartKit/Utils/SleepChartConstants.swift +++ b/Sources/SleepChartKit/Utils/SleepChartConstants.swift @@ -21,7 +21,7 @@ public enum SleepChartConstants { public static let componentSpacing: CGFloat = 8 /// Padding for chart legend items - public static let legendItemSpacing: CGFloat = 4 + public static let legendItemSpacing: CGFloat = 20 /// Horizontal padding for time axis labels public static let axisHorizontalPadding: CGFloat = 4 @@ -70,10 +70,13 @@ public enum SleepChartConstants { public static let dottedLineOpacity: CGFloat = 0.5 /// Line width for stage connector curves - public static let connectorLineWidth: CGFloat = 1.5 + public static let connectorLineWidth: CGFloat = 1 + + /// Sleep stage connector offset + public static let connectorOffset: CGFloat = 0.5 /// Opacity for stage connector curves - public static let connectorOpacity: CGFloat = 0.4 + public static let connectorOpacity: CGFloat = 0.5 /// Control point ratio for connector curve smoothness public static let connectorControlPointRatio1: CGFloat = 0.3 @@ -94,4 +97,4 @@ public enum SleepChartConstants { /// Height extension for dotted lines below the chart public static let dottedLinesHeightExtension: CGFloat = 15 -} \ No newline at end of file +}