├── .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 | Shots Mockups (48) 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 | Gym Hero Frame 481030 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 | Shots Mockups (43) 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 | } --------------------------------------------------------------------------------