├── .gitignore
├── LICENSE
├── Package.swift
├── README.md
├── Sources
└── SleepChartKit
│ ├── Components
│ ├── SleepChartView.swift
│ ├── SleepCircularChartView.swift
│ ├── SleepLegendView.swift
│ ├── SleepTimeAxisView.swift
│ └── SleepTimelineGraph.swift
│ ├── Examples
│ ├── CircularChartExample.swift
│ └── HealthKitExample.swift
│ ├── Extensions
│ ├── SleepChartView+HealthKit.swift
│ └── SleepCircularChartView+HealthKit.swift
│ ├── Models
│ ├── SleepChartStyle.swift
│ ├── SleepSample.swift
│ ├── SleepStage.swift
│ └── TimeSpan.swift
│ ├── Services
│ ├── AppleSleepColorProvider.swift
│ ├── DurationFormatter.swift
│ ├── SleepStageColorProvider.swift
│ ├── SleepStageDisplayNameProvider.swift
│ └── TimeSpanGenerator.swift
│ └── Utils
│ └── SleepChartConstants.swift
└── Tests
└── SleepChartKitTests
├── CircularChartTests.swift
└── SleepChartKitTests.swift
/.gitignore:
--------------------------------------------------------------------------------
1 | # Swift Package Manager
2 | .build/
3 | .swiftpm/
4 |
5 | # Xcode
6 | *.xcodeproj
7 | *.xcworkspace
8 | xcuserdata/
9 | DerivedData/
10 |
11 | # macOS
12 | .DS_Store
13 |
14 | # IDE
15 | .vscode/
16 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | MIT License
2 |
3 | Copyright (c) 2025 Daniel James Tronca
4 |
5 | Permission is hereby granted, free of charge, to any person obtaining a copy
6 | of this software and associated documentation files (the "Software"), to deal
7 | in the Software without restriction, including without limitation the rights
8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9 | copies of the Software, and to permit persons to whom the Software is
10 | furnished to do so, subject to the following conditions:
11 |
12 | The above copyright notice and this permission notice shall be included in all
13 | copies or substantial portions of the Software.
14 |
15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21 | SOFTWARE.
--------------------------------------------------------------------------------
/Package.swift:
--------------------------------------------------------------------------------
1 | // swift-tools-version: 6.1
2 | // The swift-tools-version declares the minimum version of Swift required to build this package.
3 |
4 | import PackageDescription
5 |
6 | let package = Package(
7 | name: "SleepChartKit",
8 | platforms: [
9 | .iOS(.v15),
10 | .macOS(.v12),
11 | .watchOS(.v8),
12 | .tvOS(.v15)
13 | ],
14 | products: [
15 | .library(
16 | name: "SleepChartKit",
17 | targets: ["SleepChartKit"]),
18 | ],
19 | targets: [
20 | // Targets are the basic building blocks of a package, defining a module or a test suite.
21 | // Targets can depend on other targets in this package and products from dependencies.
22 | .target(
23 | name: "SleepChartKit"),
24 | .testTarget(
25 | name: "SleepChartKitTests",
26 | dependencies: ["SleepChartKit"]),
27 | ]
28 | )
29 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # SleepChartKit
2 |
3 |
4 |
5 | A clean, lightweight SwiftUI package for displaying beautiful sleep stage visualizations with comprehensive HealthKit integration.
6 |
7 | ## Features
8 |
9 | - 📊 **Timeline Visualization** - Interactive sleep stage timeline with smooth stage transitions
10 | - 🧘 **Minimal Style** - Lightweight timeline view without legends or axis chrome
11 | - 🎨 **Customizable Colors** - Define your own color scheme for different sleep stages
12 | - ⏰ **Time Axis** - Clear time labels showing sleep session duration
13 | - 📋 **Legend** - Duration summary for each sleep stage
14 | - 🏥 **HealthKit Integration** - Native support for `HKCategoryValueSleepAnalysis` data
15 | - 🌍 **Localization Support** - Configurable display names for internationalization
16 | - 🔧 **SOLID Architecture** - Clean, testable, and extensible design
17 | - 📱 **Cross-Platform** - iOS 15+, macOS 12+, watchOS 8+, tvOS 15+
18 |
19 | ## Installation
20 |
21 | ### Swift Package Manager
22 |
23 | Add SleepChartKit to your project via Xcode:
24 |
25 | 1. File → Add Package Dependencies
26 | 2. Enter: `https://github.com/DanielJamesTronca/SleepChartKit`
27 | 3. Select version and add to target
28 |
29 | Or add to your `Package.swift`:
30 |
31 | ```swift
32 | dependencies: [
33 | .package(url: "https://github.com/DanielJamesTronca/SleepChartKit", from: "1.2.0")
34 | ]
35 | ```
36 |
37 | ## Quick Start
38 |
39 | ### Basic Usage
40 |
41 | ```swift
42 | import SwiftUI
43 | import SleepChartKit
44 |
45 | struct ContentView: View {
46 | let sleepSamples = [
47 | SleepSample(stage: .asleepDeep, startDate: date1, endDate: date2),
48 | SleepSample(stage: .asleepCore, startDate: date2, endDate: date3),
49 | SleepSample(stage: .asleepREM, startDate: date3, endDate: date4),
50 | SleepSample(stage: .awake, startDate: date4, endDate: date5)
51 | ]
52 |
53 | var body: some View {
54 | SleepChartView(samples: sleepSamples)
55 | .padding()
56 | }
57 | }
58 | ```
59 |
60 | ### Minimal Timeline
61 |
62 | Use the minimal style when you only need the stage bars without the axis or legend:
63 |
64 | ```swift
65 | SleepChartView(
66 | samples: sleepSamples,
67 | style: .minimal
68 | )
69 | ```
70 |
71 | ### Circular Chart
72 |
73 | The circular chart displays sleep duration as a percentage of a configurable threshold, starting from the top (12 o'clock) and filling clockwise:
74 |
75 | ```swift
76 | import SwiftUI
77 | import SleepChartKit
78 |
79 | struct CircularChartView: View {
80 | let sleepSamples = [
81 | SleepSample(stage: .asleepDeep, startDate: date1, endDate: date2),
82 | SleepSample(stage: .asleepCore, startDate: date2, endDate: date3),
83 | SleepSample(stage: .asleepREM, startDate: date3, endDate: date4)
84 | ]
85 |
86 | var body: some View {
87 | VStack(spacing: 30) {
88 | // Basic circular chart (9-hour threshold by default)
89 | SleepCircularChartView(samples: sleepSamples)
90 |
91 | // Custom threshold and styling
92 | SleepCircularChartView(
93 | samples: sleepSamples,
94 | lineWidth: 20,
95 | size: 200,
96 | showIcons: false,
97 | thresholdHours: 8.0
98 | )
99 | }
100 | .padding()
101 | }
102 | }
103 | ```
104 |
105 | #### Circular Chart Parameters
106 |
107 | - `thresholdHours` - Sleep duration threshold for percentage calculation (default: 9.0 hours)
108 | - `showIcons` - Display sun/moon icons at start/end of sleep arc (default: true)
109 | - `lineWidth` - Width of the circular segments (default: 16)
110 | - `size` - Size of the circular chart (default: 160)
111 | - `showLabels` - Show duration and time labels in center (default: true)
112 |
113 | **Example:** If a user sleeps 7 hours with a 9-hour threshold, the circle fills 77.8% (7/9) of the way around.
114 |
115 | ### HealthKit Integration
116 |
117 |
118 |
119 | ```swift
120 | import SwiftUI
121 | import HealthKit
122 | import SleepChartKit
123 |
124 | @available(iOS 16.0, *)
125 | struct HealthKitSleepView: View {
126 | @State private var healthKitSamples: [HKCategorySample] = []
127 |
128 | var body: some View {
129 | // Direct HealthKit integration
130 | SleepChartView(healthKitSamples: healthKitSamples)
131 | .padding()
132 | .onAppear {
133 | loadHealthKitData()
134 | }
135 | }
136 |
137 | private func loadHealthKitData() {
138 | // Your HealthKit data loading logic
139 | // healthKitSamples = fetchedSamples
140 | }
141 | }
142 | ```
143 |
144 | ## Sleep Stages
145 |
146 | SleepChartKit supports the following sleep stages:
147 |
148 | - **Awake** - Periods of wakefulness
149 | - **REM Sleep** - Rapid Eye Movement sleep
150 | - **Light Sleep** - Core/light sleep stages
151 | - **Deep Sleep** - Deep sleep stages
152 | - **Unspecified Sleep** - General sleep periods
153 | - **In Bed** - Time spent in bed (filtered when other stages present)
154 |
155 | ## Customization
156 |
157 | ### Custom Colors
158 |
159 | ```swift
160 | struct MyColorProvider: SleepStageColorProvider {
161 | func color(for stage: SleepStage) -> Color {
162 | switch stage {
163 | case .awake: return .red
164 | case .asleepREM: return .purple
165 | case .asleepCore: return .blue
166 | case .asleepDeep: return .indigo
167 | case .asleepUnspecified: return .gray
168 | case .inBed: return .secondary
169 | }
170 | }
171 | }
172 |
173 | SleepChartView(
174 | samples: sleepSamples,
175 | colorProvider: MyColorProvider()
176 | )
177 | ```
178 |
179 | ### Custom Display Names
180 |
181 | ```swift
182 | // Using custom names
183 | let customNameProvider = CustomSleepStageDisplayNameProvider(customNames: [
184 | .awake: "Awake",
185 | .asleepREM: "REM Sleep",
186 | .asleepCore: "Light Sleep",
187 | .asleepDeep: "Deep Sleep",
188 | .asleepUnspecified: "Unknown Sleep",
189 | .inBed: "In Bed"
190 | ])
191 |
192 | SleepChartView(
193 | samples: sleepSamples,
194 | displayNameProvider: customNameProvider
195 | )
196 | ```
197 |
198 | ### Localization Support
199 |
200 | ```swift
201 | // Using localized strings from your app's bundle
202 | let localizedProvider = LocalizedSleepStageDisplayNameProvider(
203 | bundle: .main,
204 | tableName: "SleepStages"
205 | )
206 |
207 | SleepChartView(
208 | samples: sleepSamples,
209 | displayNameProvider: localizedProvider
210 | )
211 | ```
212 |
213 | Create a `SleepStages.strings` file:
214 | ```
215 | "sleep_stage_awake" = "Awake";
216 | "sleep_stage_asleepREM" = "REM Sleep";
217 | "sleep_stage_asleepCore" = "Light Sleep";
218 | "sleep_stage_asleepDeep" = "Deep Sleep";
219 | "sleep_stage_asleepUnspecified" = "Sleep";
220 | "sleep_stage_inBed" = "In Bed";
221 | ```
222 |
223 | ### Custom Duration Formatting
224 |
225 | ```swift
226 | struct MyDurationFormatter: DurationFormatter {
227 | func format(_ duration: TimeInterval) -> String {
228 | let hours = Int(duration) / 3600
229 | let minutes = (Int(duration) % 3600) / 60
230 | return "\(hours):\(String(format: "%02d", minutes))"
231 | }
232 | }
233 |
234 | SleepChartView(
235 | samples: sleepSamples,
236 | durationFormatter: MyDurationFormatter()
237 | )
238 | ```
239 |
240 | ### Complete Customization Example
241 |
242 | ```swift
243 | @available(iOS 16.0, *)
244 | SleepChartView(
245 | healthKitSamples: healthKitSamples,
246 | colorProvider: MyColorProvider(),
247 | durationFormatter: MyDurationFormatter(),
248 | displayNameProvider: LocalizedSleepStageDisplayNameProvider()
249 | )
250 | ```
251 |
252 | ## Architecture
253 |
254 | SleepChartKit follows SOLID principles with a clean, modular architecture:
255 |
256 | ### Core Components
257 |
258 | - **SleepChartView** - Main timeline chart container
259 | - **SleepCircularChartView** - Circular percentage-based chart with threshold support
260 | - **SleepTimelineGraph** - Timeline visualization with Canvas
261 | - **SleepTimeAxisView** - Time labels and axis
262 | - **SleepLegendView** - Sleep stage legend
263 |
264 | ### Data Models
265 |
266 | - **SleepSample** - Represents a sleep period
267 | - **SleepStage** - Enum of sleep stages
268 | - **TimeSpan** - Time axis labels
269 |
270 | ### Services (Protocols)
271 |
272 | - **SleepStageColorProvider** - Stage color customization
273 | - **SleepStageDisplayNameProvider** - Stage display name customization
274 | - **DurationFormatter** - Duration text formatting
275 | - **TimeSpanGenerator** - Time axis customization
276 |
277 | ## HealthKit Integration
278 |
279 | SleepChartKit provides native support for HealthKit sleep analysis data with automatic conversion and type safety.
280 |
281 | ### Direct HealthKit Usage
282 |
283 | ```swift
284 | import HealthKit
285 | import SleepChartKit
286 |
287 | @available(iOS 16.0, *)
288 | func createChart(with healthKitSamples: [HKCategorySample]) -> some View {
289 | // Direct integration - automatically converts HealthKit samples
290 | SleepChartView(healthKitSamples: healthKitSamples)
291 | }
292 | ```
293 |
294 | ### Manual Conversion
295 |
296 | ```swift
297 | // Convert individual samples
298 | let sleepSample = SleepSample(healthKitSample: hkSample)
299 |
300 | // Batch convert samples
301 | let sleepSamples = SleepSample.samples(from: healthKitSamples)
302 |
303 | // Create chart with converted samples
304 | SleepChartView(samples: sleepSamples)
305 | ```
306 |
307 | ### Working with SleepStage and HealthKit
308 |
309 | ```swift
310 | // Convert between SleepStage and HKCategoryValueSleepAnalysis
311 | let sleepStage = SleepStage(healthKitValue: .asleepREM) // Optional conversion
312 | let healthKitValue = sleepStage.healthKitValue // Direct conversion
313 |
314 | // Use with color providers
315 | let color = colorProvider.color(for: .asleepREM) // HKCategoryValueSleepAnalysis
316 | ```
317 |
318 | ### Availability
319 |
320 | HealthKit integration requires:
321 | - iOS 16.0+ / macOS 13.0+ / watchOS 9.0+
322 | - HealthKit framework available
323 |
324 | ## Requirements
325 |
326 | - iOS 15.0+
327 | - macOS 12.0+
328 | - watchOS 8.0+
329 | - tvOS 15.0+
330 | - Xcode 13.0+
331 | - Swift 5.5+
332 |
333 | ## Contributing
334 |
335 | 1. Fork the repository
336 | 2. Create your feature branch (`git checkout -b feature/amazing-feature`)
337 | 3. Commit your changes (`git commit -m 'Add amazing feature'`)
338 | 4. Push to the branch (`git push origin feature/amazing-feature`)
339 | 5. Open a Pull Request
340 |
341 | ## License
342 |
343 | SleepChartKit is available under the MIT license. See the LICENSE file for more info.
344 |
345 | ## Example
346 |
347 |
348 | *Sample sleep chart showing a night's sleep with deep sleep, REM, and wake periods.*
349 |
--------------------------------------------------------------------------------
/Sources/SleepChartKit/Components/SleepChartView.swift:
--------------------------------------------------------------------------------
1 | import SwiftUI
2 |
3 | /// A SwiftUI view that displays sleep data as a timeline, circular chart, or minimalist timeline.
4 | ///
5 | /// The chart can display sleep stages as horizontal bars (timeline style), as a
6 | /// minimalist timeline without overlays (minimal style), or as color-coded segments
7 | /// around a circle (circular style). Each style supports customizable colors, and
8 | /// timeline-based styles can optionally include legends and axes.
9 | ///
10 | /// ## Usage
11 | /// ```swift
12 | /// // Basic timeline usage
13 | /// SleepChartView(samples: sleepSamples)
14 | ///
15 | /// // Circular chart
16 | /// SleepChartView(
17 | /// samples: sleepSamples,
18 | /// style: .circular,
19 | /// circularConfig: CircularChartConfiguration(size: 200, lineWidth: 20)
20 | /// )
21 | ///
22 | /// // With custom providers
23 | /// SleepChartView(
24 | /// samples: sleepSamples,
25 | /// colorProvider: customColorProvider,
26 | /// displayNameProvider: localizedNameProvider
27 | /// )
28 | ///
29 | /// // Minimal timeline without axis or legend
30 | /// SleepChartView(
31 | /// samples: sleepSamples,
32 | /// style: .minimal
33 | /// )
34 | /// ```
35 | public struct SleepChartView: View {
36 |
37 | // MARK: - Properties
38 |
39 | /// The sleep samples to display in the chart
40 | private let samples: [SleepSample]
41 |
42 | /// The visual style of the chart
43 | private let style: SleepChartStyle
44 |
45 | /// Configuration for circular charts
46 | private let circularConfig: CircularChartConfiguration
47 |
48 | /// Provider for sleep stage colors
49 | private let colorProvider: SleepStageColorProvider
50 |
51 | /// Formatter for displaying durations in the legend
52 | private let durationFormatter: DurationFormatter
53 |
54 | /// Generator for time span markers on the axis
55 | private let timeSpanGenerator: TimeSpanGenerator
56 |
57 | /// Provider for sleep stage display names
58 | private let displayNameProvider: SleepStageDisplayNameProvider
59 |
60 | // MARK: - Initialization
61 |
62 | /// Creates a new sleep chart view with the specified configuration.
63 | ///
64 | /// - Parameters:
65 | /// - samples: The sleep samples to display
66 | /// - style: The visual style of the chart (timeline, circular, or minimal; default: .timeline)
67 | /// - circularConfig: Configuration for circular charts (default: .default)
68 | /// - colorProvider: Provider for sleep stage colors (default: DefaultSleepStageColorProvider)
69 | /// - durationFormatter: Formatter for duration display (default: DefaultDurationFormatter)
70 | /// - timeSpanGenerator: Generator for time axis markers (default: DefaultTimeSpanGenerator)
71 | /// - displayNameProvider: Provider for stage names (default: DefaultSleepStageDisplayNameProvider)
72 | public init(
73 | samples: [SleepSample],
74 | style: SleepChartStyle = .timeline,
75 | circularConfig: CircularChartConfiguration = .default,
76 | colorProvider: SleepStageColorProvider = DefaultSleepStageColorProvider(),
77 | durationFormatter: DurationFormatter = DefaultDurationFormatter(),
78 | timeSpanGenerator: TimeSpanGenerator = DefaultTimeSpanGenerator(),
79 | displayNameProvider: SleepStageDisplayNameProvider = DefaultSleepStageDisplayNameProvider()
80 | ) {
81 | self.samples = samples
82 | self.style = style
83 | self.circularConfig = circularConfig
84 | self.colorProvider = colorProvider
85 | self.durationFormatter = durationFormatter
86 | self.timeSpanGenerator = timeSpanGenerator
87 | self.displayNameProvider = displayNameProvider
88 | }
89 |
90 | // MARK: - Computed Properties
91 |
92 | /// Aggregated sleep data by stage, calculating total duration for each stage
93 | private var sleepData: [SleepStage: TimeInterval] {
94 | var data: [SleepStage: TimeInterval] = [:]
95 | for sample in samples {
96 | data[sample.stage, default: 0] += sample.duration
97 | }
98 | return data
99 | }
100 |
101 | /// Active sleep stages sorted by their natural order
102 | private var activeStages: [SleepStage] {
103 | sleepData.keys.sorted { $0.sortOrder < $1.sortOrder }
104 | }
105 |
106 | /// Time span markers for the horizontal axis
107 | private var timeSpans: [TimeSpan] {
108 | timeSpanGenerator.generateTimeSpans(for: samples)
109 | }
110 |
111 | /// Formatted start time for the axis labels
112 | private var startTime: String? {
113 | samples.first?.startDate.formatted(date: .omitted, time: .shortened)
114 | }
115 |
116 | /// Formatted end time for the axis labels
117 | private var endTime: String? {
118 | samples.last?.endDate.formatted(date: .omitted, time: .shortened)
119 | }
120 |
121 | // MARK: - Body
122 |
123 | public var body: some View {
124 | switch style {
125 | case .timeline:
126 | timelineChartView
127 | case .circular:
128 | circularChartView
129 | case .minimal:
130 | minimalChartView
131 | }
132 | }
133 |
134 | // MARK: - Chart Views
135 |
136 | /// Timeline chart view (original implementation)
137 | private var timelineChartView: some View {
138 | VStack(spacing: SleepChartConstants.componentSpacing) {
139 | // Chart area with timeline graph and dotted lines overlay
140 | chartWithDottedLinesOverlay
141 |
142 | // Time axis showing start/end times and intermediate markers
143 | SleepTimeAxisView(
144 | startTime: startTime,
145 | endTime: endTime,
146 | timeSpans: timeSpans
147 | )
148 | .padding(.top, SleepChartConstants.axisNegativeTopPadding)
149 |
150 | // Legend showing sleep stages with colors and durations
151 | SleepLegendView(
152 | activeStages: activeStages,
153 | sleepData: sleepData,
154 | colorProvider: colorProvider,
155 | durationFormatter: durationFormatter,
156 | displayNameProvider: displayNameProvider
157 | )
158 | .padding(.top, SleepChartConstants.legendTopPadding)
159 | }
160 | .frame(height: SleepChartConstants.totalChartHeight)
161 | }
162 |
163 | /// Circular chart view with optional legend
164 | private var circularChartView: some View {
165 | VStack(spacing: 20) {
166 | SleepCircularChartView(
167 | samples: samples,
168 | colorProvider: colorProvider,
169 | lineWidth: circularConfig.lineWidth,
170 | size: circularConfig.size,
171 | showLabels: circularConfig.showLabels
172 | )
173 |
174 | // Optional legend for circular charts
175 | SleepLegendView(
176 | activeStages: activeStages,
177 | sleepData: sleepData,
178 | colorProvider: colorProvider,
179 | durationFormatter: durationFormatter,
180 | displayNameProvider: displayNameProvider
181 | )
182 | }
183 | }
184 |
185 | /// Minimal timeline chart without axis, legends, or overlays
186 | private var minimalChartView: some View {
187 | SleepTimelineGraph(
188 | samples: samples,
189 | colorProvider: colorProvider
190 | )
191 | .frame(height: SleepChartConstants.chartHeight)
192 | .clipShape(RoundedRectangle(cornerRadius: SleepChartConstants.chartClipCornerRadius))
193 | }
194 |
195 | // MARK: - Private Views
196 |
197 | /// Chart area combining the sleep timeline graph with dotted vertical lines overlay
198 | private var chartWithDottedLinesOverlay: some View {
199 | ZStack(alignment: .bottom) {
200 | // Main sleep timeline graph showing sleep stages as horizontal bars
201 | SleepTimelineGraph(
202 | samples: samples,
203 | colorProvider: colorProvider
204 | )
205 | .frame(height: SleepChartConstants.chartHeight)
206 | .clipShape(RoundedRectangle(cornerRadius: SleepChartConstants.chartClipCornerRadius))
207 |
208 | // Dotted vertical lines connecting chart to time axis
209 | dottedLinesOverlay
210 | }
211 | }
212 |
213 | /// Dotted vertical lines overlay for time axis alignment
214 | private var dottedLinesOverlay: some View {
215 | GeometryReader { geometry in
216 | let axisHeight = geometry.size.height - SleepChartConstants.chartHeight
217 | let lineBottomY = geometry.size.height - (axisHeight / 2)
218 | let lineTopY = geometry.size.height - SleepChartConstants.chartHeight
219 |
220 | Path { path in
221 | // Start line
222 | path.move(to: CGPoint(x: 0, y: lineBottomY))
223 | path.addLine(to: CGPoint(x: 0, y: lineTopY))
224 |
225 | // End line
226 | path.move(to: CGPoint(x: geometry.size.width, y: lineBottomY))
227 | path.addLine(to: CGPoint(x: geometry.size.width, y: lineTopY))
228 |
229 | // Intermediate time span lines
230 | for span in timeSpans {
231 | let xPos = geometry.size.width * span.position
232 | path.move(to: CGPoint(x: xPos, y: lineBottomY))
233 | path.addLine(to: CGPoint(x: xPos, y: lineTopY))
234 | }
235 | }
236 | .stroke(
237 | style: StrokeStyle(
238 | lineWidth: SleepChartConstants.dottedLineWidth,
239 | dash: SleepChartConstants.dottedLineDashPattern
240 | )
241 | )
242 | .foregroundColor(.secondary.opacity(SleepChartConstants.dottedLineOpacity))
243 | }
244 | .frame(height: SleepChartConstants.chartHeight + SleepChartConstants.dottedLinesHeightExtension)
245 | .padding(.bottom, SleepChartConstants.dottedLinesBottomPadding)
246 | }
247 | }
248 |
--------------------------------------------------------------------------------
/Sources/SleepChartKit/Components/SleepCircularChartView.swift:
--------------------------------------------------------------------------------
1 | import SwiftUI
2 |
3 | /// A SwiftUI view that displays sleep data as a circular chart with color-coded sleep stages.
4 | ///
5 | /// The chart displays sleep stages as arc segments around a circle, with each segment's
6 | /// size proportional to the time spent in that sleep stage. The chart includes customizable
7 | /// colors and styling options.
8 | ///
9 | /// ## Usage
10 | /// ```swift
11 | /// // Basic usage with sleep samples
12 | /// SleepCircularChartView(samples: sleepSamples)
13 | ///
14 | /// // With custom styling
15 | /// SleepCircularChartView(
16 | /// samples: sleepSamples,
17 | /// colorProvider: customColorProvider,
18 | /// lineWidth: 20,
19 | /// size: 200
20 | /// )
21 | /// ```
22 | public struct SleepCircularChartView: View {
23 |
24 | // MARK: - Properties
25 |
26 | /// The sleep samples to display in the chart
27 | private let samples: [SleepSample]
28 |
29 | /// Provider for sleep stage colors
30 | private let colorProvider: SleepStageColorProvider
31 |
32 | /// Width of the circular segments
33 | private let lineWidth: CGFloat
34 |
35 | /// Size of the circular chart
36 | private let size: CGFloat
37 |
38 | /// Background color of the chart
39 | private let backgroundColor: Color
40 |
41 | /// Whether to show labels inside the circle
42 | private let showLabels: Bool
43 |
44 | /// Whether to show sun/moon icons at start/end of sleep arc
45 | private let showIcons: Bool
46 |
47 | /// Sleep duration threshold in hours (default: 9 hours)
48 | private let thresholdHours: Double
49 |
50 | // MARK: - Initialization
51 |
52 | /// Creates a new circular sleep chart view with the specified configuration.
53 | ///
54 | /// - Parameters:
55 | /// - samples: The sleep samples to display
56 | /// - colorProvider: Provider for sleep stage colors (default: DefaultSleepStageColorProvider)
57 | /// - lineWidth: Width of the circular segments (default: 16)
58 | /// - size: Size of the circular chart (default: 160)
59 | /// - backgroundColor: Background color of the chart (default: clear)
60 | /// - showLabels: Whether to show time labels (default: true)
61 | /// - showIcons: Whether to show sun/moon icons at start/end of sleep arc (default: true)
62 | /// - thresholdHours: Sleep duration threshold in hours for percentage calculation (default: 9)
63 | public init(
64 | samples: [SleepSample],
65 | colorProvider: SleepStageColorProvider = DefaultSleepStageColorProvider(),
66 | lineWidth: CGFloat = 16,
67 | size: CGFloat = 160,
68 | backgroundColor: Color = .clear,
69 | showLabels: Bool = true,
70 | showIcons: Bool = true,
71 | thresholdHours: Double = 9.0
72 | ) {
73 | self.samples = samples
74 | self.colorProvider = colorProvider
75 | self.lineWidth = lineWidth
76 | self.size = size
77 | self.backgroundColor = backgroundColor
78 | self.showLabels = showLabels
79 | self.showIcons = showIcons
80 | self.thresholdHours = thresholdHours
81 | }
82 |
83 | // MARK: - Computed Properties
84 |
85 | /// Total duration of all sleep samples
86 | private var totalDuration: TimeInterval {
87 | samples.reduce(0) { $0 + $1.duration }
88 | }
89 |
90 | /// Sleep start and end times for the entire sleep session
91 | private var sleepPeriod: (start: Date, end: Date)? {
92 | guard let firstSample = samples.first, let lastSample = samples.last else { return nil }
93 | return (start: firstSample.startDate, end: lastSample.endDate)
94 | }
95 |
96 | /// Sleep segments with calculated angles based on percentage of threshold
97 | private var sleepSegments: [SleepSegment] {
98 | guard !samples.isEmpty else { return [] }
99 |
100 | let thresholdSeconds = thresholdHours * 3600
101 | let sleepPercentage = min(totalDuration / thresholdSeconds, 1.0)
102 | let totalArcDegrees = sleepPercentage * 360
103 |
104 | var segments: [SleepSegment] = []
105 | var currentAngle: Double = -90 // Start at top (12 o'clock)
106 |
107 | for sample in samples {
108 | let samplePercentage = sample.duration / totalDuration
109 | let sampleArcDegrees = samplePercentage * totalArcDegrees
110 |
111 | let startAngle = currentAngle
112 | let endAngle = currentAngle + sampleArcDegrees
113 |
114 | segments.append(SleepSegment(
115 | stage: sample.stage,
116 | startAngle: startAngle,
117 | endAngle: endAngle,
118 | duration: sample.duration,
119 | startDate: sample.startDate,
120 | endDate: sample.endDate
121 | ))
122 |
123 | currentAngle = endAngle
124 | }
125 |
126 | return segments
127 | }
128 |
129 |
130 | /// Start time for label display
131 | private var startTime: String? {
132 | samples.first?.startDate.formatted(date: .omitted, time: .shortened)
133 | }
134 |
135 | /// End time for label display
136 | private var endTime: String? {
137 | samples.last?.endDate.formatted(date: .omitted, time: .shortened)
138 | }
139 |
140 | /// Total sleep duration formatted
141 | private var totalSleepDuration: String {
142 | let hours = Int(totalDuration) / 3600
143 | let minutes = (Int(totalDuration) % 3600) / 60
144 | return "\(hours)h \(minutes)m"
145 | }
146 |
147 | // MARK: - Body
148 |
149 | public var body: some View {
150 | ZStack {
151 | backgroundColor
152 |
153 | // Circular chart segments using Canvas for better control
154 | Canvas { context, canvasSize in
155 | drawCircularChart(context: context, canvasSize: canvasSize)
156 | }
157 |
158 | // Sun and moon symbols
159 | if showIcons && !sleepSegments.isEmpty {
160 | sleepStartEndSymbols()
161 | }
162 |
163 | // Center content
164 | if showLabels {
165 | VStack(spacing: 4) {
166 | Text(totalSleepDuration)
167 | .font(.title2)
168 | .fontWeight(.semibold)
169 | .foregroundColor(.primary)
170 |
171 | if let startTime = startTime, let endTime = endTime {
172 | Text("\(startTime) - \(endTime)")
173 | .font(.caption)
174 | .foregroundColor(.secondary)
175 | }
176 | }
177 | }
178 | }
179 | .frame(width: size + 32, height: size + 32)
180 | }
181 |
182 | // MARK: - Symbol Views
183 |
184 | /// Sun and moon symbols positioned at the start and end of the sleep arc
185 | @ViewBuilder
186 | private func sleepStartEndSymbols() -> some View {
187 | if let firstSegment = sleepSegments.first,
188 | let lastSegment = sleepSegments.last {
189 |
190 | // Position symbols very close to the start and end points (minimal padding)
191 | let paddingDegrees: Double = 2
192 | let firstSymbolAngle = firstSegment.startAngle + paddingDegrees
193 | let lastSymbolAngle = lastSegment.endAngle - paddingDegrees
194 |
195 | // Position symbols on the inner ring segments
196 | let outerRadius = size / 2
197 | let outerRingRadius = outerRadius - (lineWidth * 0.6)
198 | let innerRingRadius = outerRingRadius
199 | let symbolOffset = innerRingRadius
200 |
201 | // Moon symbol at start of sleep arc
202 | Image(systemName: "moon.fill")
203 | .foregroundColor(.white)
204 | .font(.caption2)
205 | .offset(
206 | x: symbolOffset * cos(firstSymbolAngle * .pi / 180),
207 | y: symbolOffset * sin(firstSymbolAngle * .pi / 180)
208 | )
209 |
210 | // Sun symbol at end of sleep arc
211 | Image(systemName: "sun.max.fill")
212 | .foregroundColor(.white)
213 | .font(.caption2)
214 | .offset(
215 | x: symbolOffset * cos(lastSymbolAngle * .pi / 180),
216 | y: symbolOffset * sin(lastSymbolAngle * .pi / 180)
217 | )
218 | }
219 | }
220 |
221 | // MARK: - Drawing Methods
222 |
223 | /// Draws the circular chart using Canvas for precise control
224 | private func drawCircularChart(context: GraphicsContext, canvasSize: CGSize) {
225 | let center = CGPoint(x: canvasSize.width / 2, y: canvasSize.height / 2)
226 | // Calculate radii for concentric rings design
227 | let outerRadius = size / 2
228 | let outerRingRadius = outerRadius - (lineWidth * 0.6) // Outer background ring position
229 | let outerRingStrokeWidth = lineWidth * 1.5 // Outer ring stroke width
230 | let innerRingRadius = outerRingRadius // Inner ring centered on outer ring stroke
231 |
232 | // Draw outer background ring (complete circle) FIRST
233 | let outerRingPath = Path { path in
234 | path.addArc(
235 | center: center,
236 | radius: outerRingRadius,
237 | startAngle: Angle.degrees(0),
238 | endAngle: Angle.degrees(360),
239 | clockwise: false
240 | )
241 | }
242 |
243 | context.stroke(
244 | outerRingPath,
245 | with: .color(Color.gray.opacity(0.2)),
246 | style: StrokeStyle(
247 | lineWidth: outerRingStrokeWidth,
248 | lineCap: .round,
249 | lineJoin: .round
250 | )
251 | )
252 |
253 |
254 | // Draw sleep stage segments on inner ring
255 | for segment in sleepSegments {
256 | let startAngle = Angle.degrees(segment.startAngle)
257 | let endAngle = Angle.degrees(segment.endAngle)
258 | let segmentColor = colorProvider.color(for: segment.stage)
259 |
260 | // Create path for the arc segment on inner ring
261 | var path = Path()
262 | path.addArc(
263 | center: center,
264 | radius: innerRingRadius,
265 | startAngle: startAngle,
266 | endAngle: endAngle,
267 | clockwise: false
268 | )
269 |
270 | // Stroke segments on inner ring with straight caps
271 | context.stroke(
272 | path,
273 | with: .color(segmentColor),
274 | style: StrokeStyle(
275 | lineWidth: lineWidth,
276 | lineCap: .butt,
277 | lineJoin: .miter
278 | )
279 | )
280 | }
281 |
282 | // Add custom rounded caps for inner ring segments
283 | if let firstSegment = sleepSegments.first,
284 | let lastSegment = sleepSegments.last {
285 |
286 | let firstStartAngle = Angle.degrees(firstSegment.startAngle)
287 | let lastEndAngle = Angle.degrees(lastSegment.endAngle)
288 |
289 | // Calculate positions for the inner ring caps
290 | let firstStartX = center.x + innerRingRadius * cos(CGFloat(firstStartAngle.radians))
291 | let firstStartY = center.y + innerRingRadius * sin(CGFloat(firstStartAngle.radians))
292 | let lastEndX = center.x + innerRingRadius * cos(CGFloat(lastEndAngle.radians))
293 | let lastEndY = center.y + innerRingRadius * sin(CGFloat(lastEndAngle.radians))
294 |
295 | // Draw semicircle cap at start of inner ring
296 | let startCapColor = colorProvider.color(for: firstSegment.stage)
297 | let startCapPath = Path { path in
298 | let innerCapRadius = innerRingRadius - lineWidth / 2
299 |
300 | let innerX = center.x + innerCapRadius * cos(CGFloat(firstStartAngle.radians))
301 | let innerY = center.y + innerCapRadius * sin(CGFloat(firstStartAngle.radians))
302 |
303 | // Draw semicircle cap for inner ring
304 | path.move(to: CGPoint(x: innerX, y: innerY))
305 | path.addArc(
306 | center: CGPoint(x: firstStartX, y: firstStartY),
307 | radius: lineWidth / 2,
308 | startAngle: firstStartAngle + Angle.degrees(180),
309 | endAngle: firstStartAngle,
310 | clockwise: false
311 | )
312 | path.addLine(to: CGPoint(x: innerX, y: innerY))
313 | }
314 | context.fill(startCapPath, with: .color(startCapColor))
315 |
316 | // Draw semicircle cap at end of inner ring
317 | let endCapColor = colorProvider.color(for: lastSegment.stage)
318 | let endCapPath = Path { path in
319 | let innerCapRadius = innerRingRadius - lineWidth / 2
320 |
321 | let innerX = center.x + innerCapRadius * cos(CGFloat(lastEndAngle.radians))
322 | let innerY = center.y + innerCapRadius * sin(CGFloat(lastEndAngle.radians))
323 |
324 | // Draw semicircle cap for inner ring
325 | path.move(to: CGPoint(x: innerX, y: innerY))
326 | path.addArc(
327 | center: CGPoint(x: lastEndX, y: lastEndY),
328 | radius: lineWidth / 2,
329 | startAngle: lastEndAngle,
330 | endAngle: lastEndAngle + Angle.degrees(180),
331 | clockwise: false
332 | )
333 | path.addLine(to: CGPoint(x: innerX, y: innerY))
334 | }
335 | context.fill(endCapPath, with: .color(endCapColor))
336 | }
337 | }
338 | }
339 |
340 | // MARK: - Supporting Types
341 |
342 | /// Represents a segment of the circular sleep chart
343 | private struct SleepSegment {
344 | let id = UUID()
345 | let stage: SleepStage
346 | let startAngle: Double
347 | let endAngle: Double
348 | let duration: TimeInterval
349 | let startDate: Date
350 | let endDate: Date
351 | }
352 |
353 |
354 | // MARK: - Preview
355 |
356 | #if DEBUG
357 | struct SleepCircularChartView_Previews: PreviewProvider {
358 | static var previews: some View {
359 | let calendar = Calendar.current
360 | let baseDate = calendar.startOfDay(for: Date())
361 | let startDate = calendar.date(byAdding: .hour, value: 22, to: baseDate)!
362 |
363 | let samples = [
364 | SleepSample(stage: .inBed, startDate: startDate, endDate: calendar.date(byAdding: .minute, value: 15, to: startDate)!),
365 | SleepSample(stage: .asleepCore, startDate: calendar.date(byAdding: .minute, value: 15, to: startDate)!, endDate: calendar.date(byAdding: .hour, value: 2, to: startDate)!),
366 | SleepSample(stage: .asleepDeep, startDate: calendar.date(byAdding: .hour, value: 2, to: startDate)!, endDate: calendar.date(byAdding: .hour, value: 4, to: startDate)!),
367 | SleepSample(stage: .asleepREM, startDate: calendar.date(byAdding: .hour, value: 4, to: startDate)!, endDate: calendar.date(byAdding: .hour, value: 6, to: startDate)!),
368 | SleepSample(stage: .awake, startDate: calendar.date(byAdding: .hour, value: 6, to: startDate)!, endDate: calendar.date(byAdding: .hour, value: 6, to: startDate)!.addingTimeInterval(600)),
369 | SleepSample(stage: .asleepCore, startDate: calendar.date(byAdding: .hour, value: 6, to: startDate)!.addingTimeInterval(600), endDate: calendar.date(byAdding: .hour, value: 8, to: startDate)!)
370 | ]
371 |
372 | VStack(spacing: 40) {
373 | SleepCircularChartView(samples: samples)
374 |
375 | SleepCircularChartView(
376 | samples: samples,
377 | lineWidth: 20,
378 | size: 200
379 | )
380 | }
381 | .padding()
382 | .previewLayout(.sizeThatFits)
383 | }
384 | }
385 | #endif
386 |
--------------------------------------------------------------------------------
/Sources/SleepChartKit/Components/SleepLegendView.swift:
--------------------------------------------------------------------------------
1 | import SwiftUI
2 |
3 | public struct SleepLegendView: View {
4 | private let activeStages: [SleepStage]
5 | private let sleepData: [SleepStage: TimeInterval]
6 | private let colorProvider: SleepStageColorProvider
7 | private let durationFormatter: DurationFormatter
8 | private let displayNameProvider: SleepStageDisplayNameProvider
9 |
10 | public init(
11 | activeStages: [SleepStage],
12 | sleepData: [SleepStage: TimeInterval],
13 | colorProvider: SleepStageColorProvider = DefaultSleepStageColorProvider(),
14 | durationFormatter: DurationFormatter = DefaultDurationFormatter(),
15 | displayNameProvider: SleepStageDisplayNameProvider = DefaultSleepStageDisplayNameProvider()
16 | ) {
17 | self.activeStages = activeStages
18 | self.sleepData = sleepData
19 | self.colorProvider = colorProvider
20 | self.durationFormatter = durationFormatter
21 | self.displayNameProvider = displayNameProvider
22 | }
23 |
24 | // MARK: - Layout Configuration
25 |
26 | /// Grid configuration for legend items with adaptive sizing
27 | private var columns: [GridItem] {
28 | [GridItem(.adaptive(
29 | minimum: SleepChartConstants.legendItemMinWidth,
30 | maximum: SleepChartConstants.legendItemMaxWidth
31 | ))]
32 | }
33 |
34 | // MARK: - Body
35 |
36 | public var body: some View {
37 | LazyVGrid(
38 | columns: columns,
39 | alignment: .leading,
40 | spacing: SleepChartConstants.legendItemSpacing
41 | ) {
42 | ForEach(activeStages, id: \.self) { stage in
43 | // Only show stages that have recorded time
44 | if let duration = sleepData[stage], duration > 0 {
45 | LegendItem(
46 | stage: stage,
47 | duration: duration,
48 | colorProvider: colorProvider,
49 | durationFormatter: durationFormatter,
50 | displayNameProvider: displayNameProvider
51 | )
52 | }
53 | }
54 | }
55 | }
56 | }
57 |
58 | /// A single legend item displaying a sleep stage with its color, name, and duration.
59 | ///
60 | /// This view shows a colored circle indicator, the stage name, and formatted duration
61 | /// in a horizontal layout suitable for use in a legend grid.
62 | private struct LegendItem: View {
63 |
64 | // MARK: - Properties
65 |
66 | /// The sleep stage this item represents
67 | let stage: SleepStage
68 |
69 | /// The total duration for this sleep stage
70 | let duration: TimeInterval
71 |
72 | /// Provider for the stage color
73 | let colorProvider: SleepStageColorProvider
74 |
75 | /// Formatter for the duration display
76 | let durationFormatter: DurationFormatter
77 |
78 | /// Provider for the stage display name
79 | let displayNameProvider: SleepStageDisplayNameProvider
80 |
81 | // MARK: - Body
82 |
83 | var body: some View {
84 | HStack(spacing: SleepChartConstants.legendItemSpacing) {
85 | // Color indicator circle
86 | Circle()
87 | .fill(colorProvider.color(for: stage))
88 | .frame(
89 | width: SleepChartConstants.legendCircleSize,
90 | height: SleepChartConstants.legendCircleSize
91 | )
92 |
93 | // Stage name
94 | Text(displayNameProvider.displayName(for: stage))
95 | .font(.caption)
96 | .foregroundColor(.secondary)
97 |
98 | // Duration
99 | Text(durationFormatter.format(duration))
100 | .font(.caption.weight(.semibold))
101 | .foregroundColor(.primary)
102 | }
103 | }
104 | }
--------------------------------------------------------------------------------
/Sources/SleepChartKit/Components/SleepTimeAxisView.swift:
--------------------------------------------------------------------------------
1 | import SwiftUI
2 |
3 | /// A SwiftUI view that displays time labels along the horizontal axis of the sleep chart.
4 | ///
5 | /// This view shows the start and end times of the sleep session at the edges,
6 | /// with intermediate time markers positioned according to their relative positions
7 | /// in the timeline.
8 | ///
9 | /// ## Features
10 | /// - Start and end time labels at the axis edges
11 | /// - Intermediate time markers positioned proportionally
12 | /// - Consistent typography and styling
13 | /// - Proper alignment with the chart above
14 | ///
15 | /// ## Usage
16 | /// ```swift
17 | /// SleepTimeAxisView(
18 | /// startTime: "10:30 PM",
19 | /// endTime: "6:30 AM",
20 | /// timeSpans: timeSpanMarkers
21 | /// )
22 | /// ```
23 | public struct SleepTimeAxisView: View {
24 |
25 | // MARK: - Properties
26 |
27 | /// The formatted start time string for the sleep session
28 | private let startTime: String?
29 |
30 | /// The formatted end time string for the sleep session
31 | private let endTime: String?
32 |
33 | /// Array of time span markers to display along the axis
34 | private let timeSpans: [TimeSpan]
35 |
36 | // MARK: - Initialization
37 |
38 | /// Creates a new time axis view.
39 | ///
40 | /// - Parameters:
41 | /// - startTime: The formatted start time (optional)
42 | /// - endTime: The formatted end time (optional)
43 | /// - timeSpans: Array of intermediate time markers (default: empty array)
44 | public init(startTime: String?, endTime: String?, timeSpans: [TimeSpan] = []) {
45 | self.startTime = startTime
46 | self.endTime = endTime
47 | self.timeSpans = timeSpans
48 | }
49 |
50 | // MARK: - Body
51 |
52 | public var body: some View {
53 | HStack {
54 | // Start time label
55 | Text(startTime ?? "")
56 | .font(.caption2)
57 | .foregroundColor(.secondary)
58 | .frame(minWidth: SleepChartConstants.axisLabelMinWidth, alignment: .leading)
59 |
60 | Spacer()
61 |
62 | // End time label
63 | Text(endTime ?? "")
64 | .font(.caption2)
65 | .foregroundColor(.secondary)
66 | .frame(minWidth: SleepChartConstants.axisLabelMinWidth, alignment: .trailing)
67 | }
68 | .overlay(alignment: .leading) {
69 | // Intermediate time span markers
70 | intermediateTimeMarkers
71 | }
72 | .padding(.horizontal, SleepChartConstants.axisHorizontalPadding)
73 | }
74 |
75 | // MARK: - Private Views
76 |
77 | /// Intermediate time markers positioned proportionally along the axis
78 | private var intermediateTimeMarkers: some View {
79 | GeometryReader { geometry in
80 | let availableWidth = geometry.size.width - SleepChartConstants.timeSpanWidthOffset
81 |
82 | ForEach(timeSpans, id: \.time) { span in
83 | Text(span.time)
84 | .font(.caption2)
85 | .foregroundColor(.secondary)
86 | .frame(width: SleepChartConstants.timeSpanLabelWidth)
87 | .position(
88 | x: availableWidth * span.position + SleepChartConstants.timeSpanStartPadding,
89 | y: geometry.size.height / 2
90 | )
91 | }
92 | }
93 | }
94 | }
--------------------------------------------------------------------------------
/Sources/SleepChartKit/Components/SleepTimelineGraph.swift:
--------------------------------------------------------------------------------
1 | import SwiftUI
2 |
3 | /// A SwiftUI view that renders sleep data as a timeline graph with horizontal bars for each sleep stage.
4 | ///
5 | /// This view displays sleep stages as colored horizontal bars positioned vertically by stage type,
6 | /// with smooth connecting curves between stage transitions. Each bar's width represents the duration
7 | /// of that sleep stage, and the vertical position indicates the stage type.
8 | ///
9 | /// ## Features
10 | /// - Horizontal bars for each sleep stage with configurable colors
11 | /// - Smooth curved connectors between different sleep stages
12 | /// - Automatic filtering of "inBed" stages when other sleep data exists
13 | /// - Rounded corners on bars for a polished appearance
14 | ///
15 | /// ## Usage
16 | /// ```swift
17 | /// SleepTimelineGraph(
18 | /// samples: sleepSamples,
19 | /// colorProvider: customColorProvider
20 | /// )
21 | /// ```
22 | public struct SleepTimelineGraph: View {
23 |
24 | // MARK: - Properties
25 |
26 | /// The sleep samples to render in the timeline
27 | let samples: [SleepSample]
28 |
29 | /// Provider for sleep stage colors
30 | let colorProvider: SleepStageColorProvider
31 |
32 | // MARK: - Initialization
33 |
34 | /// Creates a new sleep timeline graph.
35 | ///
36 | /// - Parameters:
37 | /// - samples: The sleep samples to display
38 | /// - colorProvider: Provider for sleep stage colors (default: DefaultSleepStageColorProvider)
39 | public init(
40 | samples: [SleepSample],
41 | colorProvider: SleepStageColorProvider = DefaultSleepStageColorProvider()
42 | ) {
43 | self.samples = samples
44 | self.colorProvider = colorProvider
45 | }
46 |
47 | // MARK: - Layout Calculations
48 |
49 | /// Calculates the vertical offset for a sleep stage bar within the timeline.
50 | ///
51 | /// Sleep stages are arranged vertically with awake at the top and deeper sleep stages below.
52 | /// Spacing is calculated to evenly distribute the stages within the available height.
53 | ///
54 | /// - Parameters:
55 | /// - stage: The sleep stage to position
56 | /// - totalHeight: Total available height for the timeline
57 | /// - barHeight: Height of individual stage bars
58 | /// - Returns: The y-offset for the stage bar
59 | private func yOffsetForStage(_ stage: SleepStage, totalHeight: CGFloat, barHeight: CGFloat) -> CGFloat {
60 | let totalBarHeight = barHeight * SleepChartConstants.stageRowCount
61 | let totalSpacing = max(0, totalHeight - totalBarHeight)
62 | let spacing = totalSpacing / (SleepChartConstants.stageRowCount - 1)
63 |
64 | switch stage {
65 | case .awake:
66 | return 0
67 | case .asleepREM:
68 | return barHeight + spacing
69 | case .asleepCore:
70 | return (barHeight + spacing) * 2
71 | case .asleepDeep:
72 | return (barHeight + spacing) * 3
73 | case .asleepUnspecified, .inBed:
74 | return (barHeight + spacing) * 4
75 | }
76 | }
77 |
78 | /// Calculates the height of individual sleep stage bars.
79 | ///
80 | /// - Parameter totalHeight: Total available height for the timeline
81 | /// - Returns: The height for each stage bar
82 | private func barHeight(totalHeight: CGFloat) -> CGFloat {
83 | return totalHeight / SleepChartConstants.stageRowCount
84 | }
85 |
86 | // MARK: - Body
87 |
88 | public var body: some View {
89 | Canvas { context, size in
90 | // Ensure we have valid sample data
91 | guard let firstSample = samples.first,
92 | let lastSample = samples.last else { return }
93 |
94 | // Calculate total time span for the sleep session
95 | let totalDuration = lastSample.endDate.timeIntervalSince(firstSample.startDate)
96 | guard totalDuration > 0 else { return }
97 |
98 | // Set up drawing dimensions
99 | let totalWidth = size.width
100 | let totalHeight = size.height
101 | let stageBarHeight = barHeight(totalHeight: totalHeight)
102 |
103 | // Track previous sample for drawing connectors
104 | var previousRect: CGRect?
105 | var previousStage: SleepStage?
106 |
107 | // Render each sleep sample as a bar with potential connectors
108 | for sample in samples {
109 | let currentStage = sample.stage
110 |
111 | // Skip "inBed" stages if other sleep stages exist (more specific data available)
112 | if currentStage == .inBed && samples.contains(where: { $0.stage != .inBed }) {
113 | continue
114 | }
115 |
116 | // Calculate bar positioning and dimensions
117 | let sampleDuration = sample.duration
118 | let startTimeOffset = sample.startDate.timeIntervalSince(firstSample.startDate)
119 |
120 | let rectX = (startTimeOffset / totalDuration) * totalWidth
121 | let rectWidth = (sampleDuration / totalDuration) * totalWidth
122 | let rectY = yOffsetForStage(currentStage, totalHeight: totalHeight, barHeight: stageBarHeight)
123 |
124 | // Ensure minimum width for visibility
125 | let finalWidth = max(SleepChartConstants.minimumBarWidth, rectWidth)
126 |
127 | // Create and render the sleep stage bar
128 | let currentRect = CGRect(x: rectX, y: rectY, width: finalWidth, height: stageBarHeight)
129 | let cornerRadius = stageBarHeight / SleepChartConstants.barCornerRadiusRatio
130 | let path = Path(roundedRect: currentRect, cornerRadius: cornerRadius)
131 | context.fill(path, with: .color(colorProvider.color(for: currentStage)))
132 |
133 | // Draw connector curve between different sleep stages
134 | if let prevRect = previousRect,
135 | let prevStage = previousStage,
136 | currentStage != prevStage {
137 | renderStageConnector(
138 | context: context,
139 | from: prevRect,
140 | to: currentRect
141 | )
142 | }
143 |
144 | // Update tracking variables for next iteration
145 | previousRect = currentRect
146 | previousStage = currentStage
147 | }
148 | }
149 | }
150 |
151 | // MARK: - Private Rendering Methods
152 |
153 | /// Renders a smooth curve connecting two sleep stage bars.
154 | ///
155 | /// - Parameters:
156 | /// - context: The graphics context for drawing
157 | /// - from: The rectangle of the starting sleep stage bar
158 | /// - to: The rectangle of the ending sleep stage bar
159 | private func renderStageConnector(
160 | context: GraphicsContext,
161 | from startRect: CGRect,
162 | to endRect: CGRect
163 | ) {
164 | let startPoint = CGPoint(x: startRect.maxX, y: startRect.midY)
165 | let endPoint = CGPoint(x: endRect.minX, y: endRect.midY)
166 |
167 | // Calculate control points for smooth Bézier curve
168 | let controlPoint1 = CGPoint(
169 | x: startPoint.x + (endPoint.x - startPoint.x) * SleepChartConstants.connectorControlPointRatio1,
170 | y: startPoint.y
171 | )
172 | let controlPoint2 = CGPoint(
173 | x: startPoint.x + (endPoint.x - startPoint.x) * SleepChartConstants.connectorControlPointRatio2,
174 | y: endPoint.y
175 | )
176 |
177 | // Create and draw the connector curve
178 | var connectorPath = Path()
179 | connectorPath.move(to: startPoint)
180 | connectorPath.addCurve(to: endPoint, control1: controlPoint1, control2: controlPoint2)
181 |
182 | context.stroke(
183 | connectorPath,
184 | with: .color(.gray.opacity(SleepChartConstants.connectorOpacity)),
185 | lineWidth: SleepChartConstants.connectorLineWidth
186 | )
187 | }
188 | }
--------------------------------------------------------------------------------
/Sources/SleepChartKit/Examples/CircularChartExample.swift:
--------------------------------------------------------------------------------
1 | import SwiftUI
2 | #if canImport(HealthKit)
3 | import HealthKit
4 | #endif
5 |
6 | /// Example demonstrating how to use the circular sleep chart
7 | public struct CircularChartExample: View {
8 |
9 | public init() {}
10 |
11 | public var body: some View {
12 | ScrollView {
13 | VStack(spacing: 40) {
14 | // Basic circular chart
15 | VStack(alignment: .leading, spacing: 16) {
16 | Text("Basic Circular Chart")
17 | .font(.headline)
18 |
19 | SleepChartView(
20 | samples: sampleSleepData,
21 | style: .circular
22 | )
23 | }
24 |
25 | // Large circular chart with custom styling
26 | VStack(alignment: .leading, spacing: 16) {
27 | Text("Large Circular Chart")
28 | .font(.headline)
29 |
30 | SleepChartView(
31 | samples: sampleSleepData,
32 | style: .circular,
33 | circularConfig: CircularChartConfiguration(
34 | lineWidth: 24,
35 | size: 200,
36 | showLabels: true
37 | )
38 | )
39 | }
40 |
41 | // Compact circular chart without labels
42 | VStack(alignment: .leading, spacing: 16) {
43 | Text("Compact Chart (No Labels)")
44 | .font(.headline)
45 |
46 | SleepCircularChartView(
47 | samples: sampleSleepData,
48 | lineWidth: 12,
49 | size: 120,
50 | showLabels: false
51 | )
52 | }
53 |
54 | // Apple-style colors with sun/moon symbols
55 | VStack(alignment: .leading, spacing: 16) {
56 | Text("Apple Health Style")
57 | .font(.headline)
58 |
59 | Text("Shows 24-hour circle with sun/moon symbols and gray areas for awake time")
60 | .font(.caption)
61 | .foregroundColor(.secondary)
62 |
63 | SleepChartView(
64 | samples: sampleSleepData,
65 | style: .circular,
66 | circularConfig: CircularChartConfiguration(
67 | lineWidth: 18,
68 | size: 200,
69 | showLabels: true
70 | ),
71 | colorProvider: AppleSleepColorProvider()
72 | )
73 | }
74 |
75 | // Custom colors
76 | VStack(alignment: .leading, spacing: 16) {
77 | Text("Custom Colors")
78 | .font(.headline)
79 |
80 | SleepChartView(
81 | samples: sampleSleepData,
82 | style: .circular,
83 | circularConfig: CircularChartConfiguration(size: 180),
84 | colorProvider: CustomColorProvider()
85 | )
86 | }
87 |
88 | #if canImport(HealthKit)
89 | if #available(iOS 16.0, macOS 13.0, watchOS 9.0, *) {
90 | // HealthKit integration example
91 | VStack(alignment: .leading, spacing: 16) {
92 | Text("HealthKit Integration")
93 | .font(.headline)
94 |
95 | Text("Use with HealthKit data:")
96 | .font(.caption)
97 | .foregroundColor(.secondary)
98 |
99 | VStack(alignment: .leading, spacing: 8) {
100 | Text("SleepChartView(")
101 | Text(" healthKitSamples: samples,")
102 | Text(" style: .circular,")
103 | Text(" circularConfig: CircularChartConfiguration(size: 200)")
104 | Text(")")
105 | }
106 | .font(.system(.caption, design: .monospaced))
107 | .padding()
108 | .background(Color.secondary.opacity(0.1))
109 | .cornerRadius(8)
110 | }
111 | }
112 | #endif
113 | }
114 | .padding()
115 | }
116 | .navigationTitle("Circular Sleep Charts")
117 | }
118 |
119 | // MARK: - Sample Data
120 |
121 | private var sampleSleepData: [SleepSample] {
122 | let calendar = Calendar.current
123 | let baseDate = calendar.startOfDay(for: Date())
124 |
125 | // Sleep session from 10:30 PM to 7:15 AM (representing a typical night)
126 | let bedTime = calendar.date(byAdding: .hour, value: 22, to: baseDate)!
127 | .addingTimeInterval(30 * 60) // 10:30 PM
128 |
129 | return [
130 | // Time in bed before falling asleep
131 | SleepSample(
132 | stage: .inBed,
133 | startDate: bedTime,
134 | endDate: bedTime.addingTimeInterval(20 * 60) // 20 minutes to fall asleep
135 | ),
136 | // Light sleep phase
137 | SleepSample(
138 | stage: .asleepCore,
139 | startDate: bedTime.addingTimeInterval(20 * 60),
140 | endDate: bedTime.addingTimeInterval(2 * 3600) // 2 hours of light sleep
141 | ),
142 | // Deep sleep phase
143 | SleepSample(
144 | stage: .asleepDeep,
145 | startDate: bedTime.addingTimeInterval(2 * 3600),
146 | endDate: bedTime.addingTimeInterval(4.5 * 3600) // 2.5 hours of deep sleep
147 | ),
148 | // REM sleep phase
149 | SleepSample(
150 | stage: .asleepREM,
151 | startDate: bedTime.addingTimeInterval(4.5 * 3600),
152 | endDate: bedTime.addingTimeInterval(7 * 3600) // 2.5 hours of REM
153 | ),
154 | // Brief awakening
155 | SleepSample(
156 | stage: .awake,
157 | startDate: bedTime.addingTimeInterval(7 * 3600),
158 | endDate: bedTime.addingTimeInterval(7.25 * 3600) // 15 minute awakening
159 | ),
160 | // Final light sleep
161 | SleepSample(
162 | stage: .asleepCore,
163 | startDate: bedTime.addingTimeInterval(7.25 * 3600),
164 | endDate: bedTime.addingTimeInterval(8.75 * 3600) // Until 7:15 AM
165 | )
166 | ]
167 | }
168 | }
169 |
170 | // MARK: - Custom Color Provider
171 |
172 | private struct CustomColorProvider: SleepStageColorProvider {
173 | func color(for stage: SleepStage) -> Color {
174 | switch stage {
175 | case .awake:
176 | return .red
177 | case .asleepREM:
178 | return .purple
179 | case .asleepCore:
180 | return .green
181 | case .asleepDeep:
182 | return .blue
183 | case .asleepUnspecified:
184 | return .gray
185 | case .inBed:
186 | return .yellow
187 | }
188 | }
189 | }
190 |
191 | // MARK: - Preview
192 |
193 | #if DEBUG
194 | struct CircularChartExample_Previews: PreviewProvider {
195 | static var previews: some View {
196 | NavigationView {
197 | CircularChartExample()
198 | }
199 | }
200 | }
201 | #endif
202 |
--------------------------------------------------------------------------------
/Sources/SleepChartKit/Examples/HealthKitExample.swift:
--------------------------------------------------------------------------------
1 | #if canImport(HealthKit)
2 | import SwiftUI
3 | import HealthKit
4 |
5 | @available(iOS 16.0, macOS 13.0, watchOS 9.0, *)
6 | public struct HealthKitExampleView: View {
7 | @State private var sleepSamples: [HKCategorySample] = []
8 |
9 | public var body: some View {
10 | VStack {
11 | if !sleepSamples.isEmpty {
12 | // Example 1: Using HealthKit samples directly
13 | SleepChartView(healthKitSamples: sleepSamples)
14 | .padding()
15 |
16 | // Example 2: Using HealthKit samples with custom display names
17 | SleepChartView(
18 | healthKitSamples: sleepSamples,
19 | displayNameProvider: CustomSleepStageDisplayNameProvider(customNames: [
20 | .awake: "Awake",
21 | .asleepREM: "REM Sleep",
22 | .asleepCore: "Light Sleep",
23 | .asleepDeep: "Deep Sleep",
24 | .asleepUnspecified: "Unknown Sleep",
25 | .inBed: "In Bed"
26 | ])
27 | )
28 | .padding()
29 |
30 | // Example 3: Using localized display names
31 | SleepChartView(
32 | healthKitSamples: sleepSamples,
33 | displayNameProvider: LocalizedSleepStageDisplayNameProvider()
34 | )
35 | .padding()
36 | } else {
37 | Text("No sleep data available")
38 | .foregroundColor(.secondary)
39 | }
40 | }
41 | .onAppear {
42 | loadSampleData()
43 | }
44 | }
45 |
46 | private func loadSampleData() {
47 | // Example of creating sample HealthKit data for demonstration
48 | // In a real app, you would fetch this from HealthKit
49 | let calendar = Calendar.current
50 | let now = Date()
51 | let yesterday = calendar.date(byAdding: .day, value: -1, to: now)!
52 |
53 | // Create sample sleep data
54 | sleepSamples = [
55 | createSample(stage: .inBed, start: yesterday.addingTimeInterval(22 * 3600), duration: 8 * 3600),
56 | createSample(stage: .awake, start: yesterday.addingTimeInterval(22 * 3600), duration: 0.5 * 3600),
57 | createSample(stage: .asleepCore, start: yesterday.addingTimeInterval(22.5 * 3600), duration: 1.5 * 3600),
58 | createSample(stage: .asleepDeep, start: yesterday.addingTimeInterval(24 * 3600), duration: 2 * 3600),
59 | createSample(stage: .asleepREM, start: yesterday.addingTimeInterval(26 * 3600), duration: 1 * 3600),
60 | createSample(stage: .asleepCore, start: yesterday.addingTimeInterval(27 * 3600), duration: 1.5 * 3600),
61 | createSample(stage: .awake, start: yesterday.addingTimeInterval(28.5 * 3600), duration: 0.5 * 3600),
62 | createSample(stage: .asleepREM, start: yesterday.addingTimeInterval(29 * 3600), duration: 1 * 3600)
63 | ]
64 | }
65 |
66 | private func createSample(stage: HKCategoryValueSleepAnalysis, start: Date, duration: TimeInterval) -> HKCategorySample {
67 | let sleepType = HKCategoryType(.sleepAnalysis)
68 | let endDate = start.addingTimeInterval(duration)
69 |
70 | return HKCategorySample(
71 | type: sleepType,
72 | value: stage.rawValue,
73 | start: start,
74 | end: endDate
75 | )
76 | }
77 | }
78 |
79 | // MARK: - Usage Examples
80 |
81 | public struct UsageExamples {
82 |
83 | /// Example: Converting HealthKit samples to SleepSample
84 | @available(iOS 16.0, macOS 13.0, watchOS 9.0, *)
85 | public static func convertHealthKitSamples(_ healthKitSamples: [HKCategorySample]) -> [SleepSample] {
86 | return SleepSample.samples(from: healthKitSamples)
87 | }
88 |
89 | /// Example: Custom color provider for HealthKit
90 | @available(iOS 16.0, macOS 13.0, watchOS 9.0, *)
91 | public static func customColorProvider() -> SleepStageColorProvider {
92 | return DefaultSleepStageColorProvider()
93 | }
94 |
95 | /// Example: Custom localization
96 | public static func localizedDisplayNames() -> SleepStageDisplayNameProvider {
97 | return LocalizedSleepStageDisplayNameProvider(bundle: .main, tableName: "SleepStages")
98 | }
99 |
100 | /// Example: Custom display names
101 | public static func customDisplayNames() -> SleepStageDisplayNameProvider {
102 | return CustomSleepStageDisplayNameProvider(customNames: [
103 | .awake: "Vigile",
104 | .asleepREM: "Sommeil REM",
105 | .asleepCore: "Sommeil Léger",
106 | .asleepDeep: "Sommeil Profond",
107 | .asleepUnspecified: "Sommeil",
108 | .inBed: "Au Lit"
109 | ])
110 | }
111 | }
112 | #endif
--------------------------------------------------------------------------------
/Sources/SleepChartKit/Extensions/SleepChartView+HealthKit.swift:
--------------------------------------------------------------------------------
1 | #if canImport(HealthKit)
2 | import SwiftUI
3 | import HealthKit
4 |
5 | @available(iOS 16.0, macOS 13.0, watchOS 9.0, *)
6 | public extension SleepChartView {
7 | /// Creates a new sleep chart view using HealthKit samples.
8 | ///
9 | /// - Parameters:
10 | /// - healthKitSamples: Array of HKCategorySample objects from HealthKit
11 | /// - style: The visual style of the chart (timeline, circular, or minimal; default: .timeline)
12 | /// - circularConfig: Configuration for circular charts (default: .default)
13 | /// - colorProvider: Provider for sleep stage colors (default: DefaultSleepStageColorProvider)
14 | /// - durationFormatter: Formatter for duration display (default: DefaultDurationFormatter)
15 | /// - timeSpanGenerator: Generator for time axis markers (default: DefaultTimeSpanGenerator)
16 | /// - displayNameProvider: Provider for stage names (default: DefaultSleepStageDisplayNameProvider)
17 | init(
18 | healthKitSamples: [HKCategorySample],
19 | style: SleepChartStyle = .timeline,
20 | circularConfig: CircularChartConfiguration = .default,
21 | colorProvider: SleepStageColorProvider = DefaultSleepStageColorProvider(),
22 | durationFormatter: DurationFormatter = DefaultDurationFormatter(),
23 | timeSpanGenerator: TimeSpanGenerator = DefaultTimeSpanGenerator(),
24 | displayNameProvider: SleepStageDisplayNameProvider = DefaultSleepStageDisplayNameProvider()
25 | ) {
26 | let sleepSamples = SleepSample.samples(from: healthKitSamples)
27 | self.init(
28 | samples: sleepSamples,
29 | style: style,
30 | circularConfig: circularConfig,
31 | colorProvider: colorProvider,
32 | durationFormatter: durationFormatter,
33 | timeSpanGenerator: timeSpanGenerator,
34 | displayNameProvider: displayNameProvider
35 | )
36 | }
37 | }
38 | #endif
39 |
--------------------------------------------------------------------------------
/Sources/SleepChartKit/Extensions/SleepCircularChartView+HealthKit.swift:
--------------------------------------------------------------------------------
1 | #if canImport(HealthKit)
2 | import HealthKit
3 | import SwiftUI
4 |
5 | @available(iOS 16.0, macOS 13.0, watchOS 9.0, *)
6 | public extension SleepCircularChartView {
7 | /// Creates a new circular sleep chart view using HealthKit samples.
8 | ///
9 | /// - Parameters:
10 | /// - healthKitSamples: Array of HKCategorySample objects from HealthKit
11 | /// - colorProvider: Provider for sleep stage colors (default: DefaultSleepStageColorProvider)
12 | /// - lineWidth: Width of the circular segments (default: 16)
13 | /// - size: Size of the circular chart (default: 160)
14 | /// - backgroundColor: Background color of the chart (default: clear)
15 | /// - showLabels: Whether to show time labels (default: true)
16 | init(
17 | healthKitSamples: [HKCategorySample],
18 | colorProvider: SleepStageColorProvider = DefaultSleepStageColorProvider(),
19 | lineWidth: CGFloat = 16,
20 | size: CGFloat = 160,
21 | backgroundColor: Color = .clear,
22 | showLabels: Bool = true
23 | ) {
24 | let sleepSamples = SleepSample.samples(from: healthKitSamples)
25 | self.init(
26 | samples: sleepSamples,
27 | colorProvider: colorProvider,
28 | lineWidth: lineWidth,
29 | size: size,
30 | backgroundColor: backgroundColor,
31 | showLabels: showLabels
32 | )
33 | }
34 | }
35 | #endif
--------------------------------------------------------------------------------
/Sources/SleepChartKit/Models/SleepChartStyle.swift:
--------------------------------------------------------------------------------
1 | import Foundation
2 |
3 | /// Defines the visual style for sleep charts
4 | public enum SleepChartStyle {
5 | /// Traditional timeline chart with horizontal bars
6 | case timeline
7 |
8 | /// Circular chart with color-coded segments
9 | case circular
10 |
11 | /// Minimal timeline chart without axis, legends, or overlays
12 | case minimal
13 | }
14 |
15 | /// Configuration options for circular sleep charts
16 | public struct CircularChartConfiguration: Sendable {
17 | /// Width of the circular segments
18 | public let lineWidth: CGFloat
19 |
20 | /// Size of the circular chart
21 | public let size: CGFloat
22 |
23 | /// Whether to show labels inside the circle
24 | public let showLabels: Bool
25 |
26 | /// Creates a new circular chart configuration
27 | ///
28 | /// - Parameters:
29 | /// - lineWidth: Width of the circular segments (default: 16)
30 | /// - size: Size of the circular chart (default: 160)
31 | /// - showLabels: Whether to show time labels (default: true)
32 | public init(
33 | lineWidth: CGFloat = 16,
34 | size: CGFloat = 160,
35 | showLabels: Bool = true
36 | ) {
37 | self.lineWidth = lineWidth
38 | self.size = size
39 | self.showLabels = showLabels
40 | }
41 |
42 | /// Default configuration for circular charts
43 | public static let `default` = CircularChartConfiguration()
44 | }
45 |
--------------------------------------------------------------------------------
/Sources/SleepChartKit/Models/SleepSample.swift:
--------------------------------------------------------------------------------
1 | import Foundation
2 | #if canImport(HealthKit)
3 | import HealthKit
4 | #endif
5 |
6 | public struct SleepSample: Hashable {
7 | public let stage: SleepStage
8 | public let startDate: Date
9 | public let endDate: Date
10 |
11 | public init(stage: SleepStage, startDate: Date, endDate: Date) {
12 | self.stage = stage
13 | self.startDate = startDate
14 | self.endDate = endDate
15 | }
16 |
17 | #if canImport(HealthKit)
18 | @available(iOS 16.0, macOS 13.0, watchOS 9.0, *)
19 | public init?(healthKitSample: HKCategorySample) {
20 | guard let sleepAnalysisValue = HKCategoryValueSleepAnalysis(rawValue: healthKitSample.value),
21 | let stage = SleepStage(healthKitValue: sleepAnalysisValue) else {
22 | return nil
23 | }
24 |
25 | self.stage = stage
26 | self.startDate = healthKitSample.startDate
27 | self.endDate = healthKitSample.endDate
28 | }
29 |
30 | @available(iOS 16.0, macOS 13.0, watchOS 9.0, *)
31 | public static func samples(from healthKitSamples: [HKCategorySample]) -> [SleepSample] {
32 | return healthKitSamples.compactMap { SleepSample(healthKitSample: $0) }
33 | }
34 | #endif
35 |
36 | public var duration: TimeInterval {
37 | endDate.timeIntervalSince(startDate)
38 | }
39 | }
--------------------------------------------------------------------------------
/Sources/SleepChartKit/Models/SleepStage.swift:
--------------------------------------------------------------------------------
1 | import Foundation
2 | #if canImport(HealthKit)
3 | import HealthKit
4 | #endif
5 |
6 | public enum SleepStage: Int, CaseIterable, Hashable {
7 | case awake = 0
8 | case asleepREM = 1
9 | case asleepCore = 2
10 | case asleepDeep = 3
11 | case asleepUnspecified = 4
12 | case inBed = 5
13 |
14 | #if canImport(HealthKit)
15 | @available(iOS 16.0, macOS 13.0, watchOS 9.0, *)
16 | public init?(healthKitValue: HKCategoryValueSleepAnalysis) {
17 | switch healthKitValue {
18 | case .awake:
19 | self = .awake
20 | case .asleepREM:
21 | self = .asleepREM
22 | case .asleepCore:
23 | self = .asleepCore
24 | case .asleepDeep:
25 | self = .asleepDeep
26 | case .asleepUnspecified:
27 | self = .asleepUnspecified
28 | case .inBed:
29 | self = .inBed
30 | @unknown default:
31 | return nil
32 | }
33 | }
34 |
35 | @available(iOS 16.0, macOS 13.0, watchOS 9.0, *)
36 | public var healthKitValue: HKCategoryValueSleepAnalysis {
37 | switch self {
38 | case .awake: return .awake
39 | case .asleepREM: return .asleepREM
40 | case .asleepCore: return .asleepCore
41 | case .asleepDeep: return .asleepDeep
42 | case .asleepUnspecified: return .asleepUnspecified
43 | case .inBed: return .inBed
44 | }
45 | }
46 | #endif
47 |
48 | public var defaultDisplayName: String {
49 | switch self {
50 | case .awake: return "Awake"
51 | case .asleepREM: return "REM"
52 | case .asleepCore: return "Light"
53 | case .asleepDeep: return "Deep"
54 | case .asleepUnspecified: return "Sleep"
55 | case .inBed: return "In Bed"
56 | }
57 | }
58 |
59 | public var sortOrder: Int {
60 | rawValue
61 | }
62 | }
63 |
--------------------------------------------------------------------------------
/Sources/SleepChartKit/Models/TimeSpan.swift:
--------------------------------------------------------------------------------
1 | import Foundation
2 | import CoreGraphics
3 |
4 | public struct TimeSpan: Hashable {
5 | public let time: String
6 | public let position: CGFloat
7 |
8 | public init(time: String, position: CGFloat) {
9 | self.time = time
10 | self.position = position
11 | }
12 | }
--------------------------------------------------------------------------------
/Sources/SleepChartKit/Services/AppleSleepColorProvider.swift:
--------------------------------------------------------------------------------
1 | import SwiftUI
2 |
3 | /// A color provider that matches Apple's sleep chart color scheme
4 | /// Used in Apple Health and Apple Watch sleep tracking
5 | public struct AppleSleepColorProvider: SleepStageColorProvider {
6 |
7 | public init() {}
8 |
9 | public func color(for stage: SleepStage) -> Color {
10 | switch stage {
11 | case .awake:
12 | // Orange/amber for awake periods
13 | return Color(red: 1.0, green: 0.6, blue: 0.0)
14 |
15 | case .asleepREM:
16 | // Cyan/light blue for REM sleep
17 | return Color(red: 0.0, green: 0.7, blue: 1.0)
18 |
19 | case .asleepCore:
20 | // Green for light/core sleep
21 | return Color(red: 0.2, green: 0.8, blue: 0.4)
22 |
23 | case .asleepDeep:
24 | // Dark blue/indigo for deep sleep
25 | return Color(red: 0.2, green: 0.4, blue: 0.9)
26 |
27 | case .asleepUnspecified:
28 | // Purple for unspecified sleep
29 | return Color(red: 0.6, green: 0.4, blue: 0.9)
30 |
31 | case .inBed:
32 | // Light gray for time in bed
33 | return Color(red: 0.7, green: 0.7, blue: 0.7)
34 | }
35 | }
36 | }
37 |
38 | /// A color provider with muted/pastel colors for circular charts
39 | public struct PastelSleepColorProvider: SleepStageColorProvider {
40 |
41 | public init() {}
42 |
43 | public func color(for stage: SleepStage) -> Color {
44 | switch stage {
45 | case .awake:
46 | return Color(red: 1.0, green: 0.8, blue: 0.6)
47 |
48 | case .asleepREM:
49 | return Color(red: 0.7, green: 0.9, blue: 1.0)
50 |
51 | case .asleepCore:
52 | return Color(red: 0.7, green: 0.95, blue: 0.8)
53 |
54 | case .asleepDeep:
55 | return Color(red: 0.6, green: 0.8, blue: 1.0)
56 |
57 | case .asleepUnspecified:
58 | return Color(red: 0.9, green: 0.8, blue: 1.0)
59 |
60 | case .inBed:
61 | return Color(red: 0.9, green: 0.9, blue: 0.9)
62 | }
63 | }
64 | }
65 |
66 | /// A high-contrast color provider for accessibility
67 | public struct HighContrastSleepColorProvider: SleepStageColorProvider {
68 |
69 | public init() {}
70 |
71 | public func color(for stage: SleepStage) -> Color {
72 | switch stage {
73 | case .awake:
74 | return .red
75 |
76 | case .asleepREM:
77 | return .blue
78 |
79 | case .asleepCore:
80 | return .green
81 |
82 | case .asleepDeep:
83 | return .purple
84 |
85 | case .asleepUnspecified:
86 | return .orange
87 |
88 | case .inBed:
89 | return .gray
90 | }
91 | }
92 | }
--------------------------------------------------------------------------------
/Sources/SleepChartKit/Services/DurationFormatter.swift:
--------------------------------------------------------------------------------
1 | import Foundation
2 |
3 | public protocol DurationFormatter {
4 | func format(_ duration: TimeInterval) -> String
5 | }
6 |
7 | public struct DefaultDurationFormatter: DurationFormatter {
8 | public init() {}
9 |
10 | public func format(_ duration: TimeInterval) -> String {
11 | let hours = Int(duration) / 3600
12 | let minutes = (Int(duration) % 3600) / 60
13 |
14 | if hours > 0 {
15 | return String(format: "%dh %dm", hours, minutes)
16 | } else {
17 | return String(format: "%dm", minutes)
18 | }
19 | }
20 | }
--------------------------------------------------------------------------------
/Sources/SleepChartKit/Services/SleepStageColorProvider.swift:
--------------------------------------------------------------------------------
1 | import SwiftUI
2 | #if canImport(HealthKit)
3 | import HealthKit
4 | #endif
5 |
6 | public protocol SleepStageColorProvider {
7 | func color(for stage: SleepStage) -> Color
8 |
9 | #if canImport(HealthKit)
10 | @available(iOS 16.0, macOS 13.0, watchOS 9.0, *)
11 | func color(for healthKitValue: HKCategoryValueSleepAnalysis) -> Color
12 | #endif
13 | }
14 |
15 | #if canImport(HealthKit)
16 | @available(iOS 16.0, macOS 13.0, watchOS 9.0, *)
17 | public extension SleepStageColorProvider {
18 | func color(for healthKitValue: HKCategoryValueSleepAnalysis) -> Color {
19 | guard let stage = SleepStage(healthKitValue: healthKitValue) else {
20 | return .gray
21 | }
22 | return color(for: stage)
23 | }
24 | }
25 | #endif
26 |
27 | public struct DefaultSleepStageColorProvider: SleepStageColorProvider {
28 | public init() {}
29 |
30 | public func color(for stage: SleepStage) -> Color {
31 | switch stage {
32 | case .awake:
33 | return .orange
34 | case .asleepREM:
35 | return .cyan
36 | case .asleepCore:
37 | return .blue
38 | case .asleepDeep:
39 | return .indigo
40 | case .asleepUnspecified:
41 | return .purple
42 | case .inBed:
43 | return .gray
44 | }
45 | }
46 |
47 | }
--------------------------------------------------------------------------------
/Sources/SleepChartKit/Services/SleepStageDisplayNameProvider.swift:
--------------------------------------------------------------------------------
1 | import Foundation
2 |
3 | public protocol SleepStageDisplayNameProvider {
4 | func displayName(for stage: SleepStage) -> String
5 | }
6 |
7 | public struct DefaultSleepStageDisplayNameProvider: SleepStageDisplayNameProvider {
8 | public init() {}
9 |
10 | public func displayName(for stage: SleepStage) -> String {
11 | stage.defaultDisplayName
12 | }
13 | }
14 |
15 | public struct CustomSleepStageDisplayNameProvider: SleepStageDisplayNameProvider {
16 | private let customNames: [SleepStage: String]
17 |
18 | public init(customNames: [SleepStage: String]) {
19 | self.customNames = customNames
20 | }
21 |
22 | public func displayName(for stage: SleepStage) -> String {
23 | customNames[stage] ?? stage.defaultDisplayName
24 | }
25 | }
26 |
27 | public struct LocalizedSleepStageDisplayNameProvider: SleepStageDisplayNameProvider {
28 | private let bundle: Bundle
29 | private let tableName: String?
30 |
31 | public init(bundle: Bundle = .main, tableName: String? = nil) {
32 | self.bundle = bundle
33 | self.tableName = tableName
34 | }
35 |
36 | public func displayName(for stage: SleepStage) -> String {
37 | let key = "sleep_stage_\(stage)"
38 | let localizedString = NSLocalizedString(key, tableName: tableName, bundle: bundle, comment: "")
39 | return localizedString != key ? localizedString : stage.defaultDisplayName
40 | }
41 | }
--------------------------------------------------------------------------------
/Sources/SleepChartKit/Services/TimeSpanGenerator.swift:
--------------------------------------------------------------------------------
1 | import Foundation
2 |
3 | public protocol TimeSpanGenerator {
4 | func generateTimeSpans(for samples: [SleepSample]) -> [TimeSpan]
5 | }
6 |
7 | public struct DefaultTimeSpanGenerator: TimeSpanGenerator {
8 | private let timeFormatter: DateFormatter
9 |
10 | public init() {
11 | self.timeFormatter = DateFormatter()
12 | self.timeFormatter.dateFormat = "HH:mm"
13 | }
14 |
15 | public func generateTimeSpans(for samples: [SleepSample]) -> [TimeSpan] {
16 | guard let firstSample = samples.first,
17 | let lastSample = samples.last else { return [] }
18 |
19 | let totalDuration = lastSample.endDate.timeIntervalSince(firstSample.startDate)
20 | guard totalDuration > 0 else { return [] }
21 |
22 | var spans: [TimeSpan] = []
23 | let intervalCount = 4
24 |
25 | for i in 1.. [SleepSample] {
109 | let calendar = Calendar.current
110 | let baseDate = calendar.startOfDay(for: Date())
111 | let startDate = calendar.date(byAdding: .hour, value: 22, to: baseDate)!
112 |
113 | return [
114 | SleepSample(
115 | stage: .asleepCore,
116 | startDate: startDate,
117 | endDate: calendar.date(byAdding: .hour, value: 2, to: startDate)!
118 | ),
119 | SleepSample(
120 | stage: .asleepDeep,
121 | startDate: calendar.date(byAdding: .hour, value: 2, to: startDate)!,
122 | endDate: calendar.date(byAdding: .hour, value: 4, to: startDate)!
123 | ),
124 | SleepSample(
125 | stage: .asleepREM,
126 | startDate: calendar.date(byAdding: .hour, value: 4, to: startDate)!,
127 | endDate: calendar.date(byAdding: .hour, value: 6, to: startDate)!
128 | )
129 | ]
130 | }
131 | }
132 |
--------------------------------------------------------------------------------
/Tests/SleepChartKitTests/SleepChartKitTests.swift:
--------------------------------------------------------------------------------
1 | import XCTest
2 | @testable import SleepChartKit
3 |
4 | final class SleepChartKitTests: XCTestCase {
5 |
6 | func testSleepSampleCreation() {
7 | let startDate = Date()
8 | let endDate = startDate.addingTimeInterval(3600) // 1 hour
9 | let sample = SleepSample(stage: .asleepDeep, startDate: startDate, endDate: endDate)
10 |
11 | XCTAssertEqual(sample.stage, .asleepDeep)
12 | XCTAssertEqual(sample.startDate, startDate)
13 | XCTAssertEqual(sample.endDate, endDate)
14 | XCTAssertEqual(sample.duration, 3600)
15 | }
16 |
17 | func testSleepStageSortOrder() {
18 | let stages: [SleepStage] = [.asleepDeep, .awake, .asleepREM, .asleepCore]
19 | let sorted = stages.sorted { $0.sortOrder < $1.sortOrder }
20 |
21 | XCTAssertEqual(sorted, [.awake, .asleepREM, .asleepCore, .asleepDeep])
22 | }
23 | }
--------------------------------------------------------------------------------