-
Notifications
You must be signed in to change notification settings - Fork 19
Circular sleep chart displays as if it is a clock #2
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
f89608b
0a7f272
4c86eb6
f623070
3175e5a
e42d6c2
e153cec
3d05c29
358cdfe
5852a68
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -68,7 +68,7 @@ public struct SleepCircularChartView: View { | |
| backgroundColor: Color = .clear, | ||
| showLabels: Bool = true, | ||
| showIcons: Bool = true, | ||
| thresholdHours: Double = 9.0 | ||
| thresholdHours: Double = 12 | ||
| ) { | ||
|
Comment on lines
69
to
72
|
||
| self.samples = samples | ||
| self.colorProvider = colorProvider | ||
|
|
@@ -104,6 +104,20 @@ public struct SleepCircularChartView: View { | |
| var segments: [SleepSegment] = [] | ||
| var currentAngle: Double = -90 // Start at top (12 o'clock) | ||
|
|
||
| //making the chart look like the sleep starts at it would on a clock | ||
| let sleepStart = samples.first!.startDate | ||
|
|
||
|
Comment on lines
+107
to
+109
|
||
| var h: Double = Double(Calendar.current.component(.hour, from: sleepStart)) | ||
| if h >= 12 { h -= 12 } | ||
|
|
||
| let m: Double = Double(Calendar.current.component(.minute, from: sleepStart)) | ||
|
|
||
| var difference: Double = (360 / 12) * h | ||
| difference += (360 / 12) * (m / 60) | ||
|
|
||
| currentAngle += difference | ||
|
Comment on lines
+107
to
+118
|
||
|
|
||
|
|
||
| for sample in samples { | ||
| let samplePercentage = sample.duration / totalDuration | ||
| let sampleArcDegrees = samplePercentage * totalArcDegrees | ||
|
|
@@ -199,7 +213,7 @@ public struct SleepCircularChartView: View { | |
| let symbolOffset = innerRingRadius | ||
|
|
||
| // Moon symbol at start of sleep arc | ||
| Image(systemName: "moon.fill") | ||
| Image(systemName: "bed.double.fill") | ||
| .foregroundColor(.white) | ||
| .font(.caption2) | ||
| .offset( | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -6,68 +6,102 @@ public struct SleepLegendView: View { | |
| private let colorProvider: SleepStageColorProvider | ||
| private let durationFormatter: DurationFormatter | ||
| private let displayNameProvider: SleepStageDisplayNameProvider | ||
| private let hideDurations: Bool | ||
|
|
||
| public init( | ||
| activeStages: [SleepStage], | ||
| sleepData: [SleepStage: TimeInterval], | ||
| colorProvider: SleepStageColorProvider = DefaultSleepStageColorProvider(), | ||
| durationFormatter: DurationFormatter = DefaultDurationFormatter(), | ||
| displayNameProvider: SleepStageDisplayNameProvider = DefaultSleepStageDisplayNameProvider() | ||
| displayNameProvider: SleepStageDisplayNameProvider = DefaultSleepStageDisplayNameProvider(), | ||
| hideDurations: Bool = false | ||
| ) { | ||
| self.activeStages = activeStages | ||
| self.sleepData = sleepData | ||
| self.colorProvider = colorProvider | ||
| self.durationFormatter = durationFormatter | ||
| self.displayNameProvider = displayNameProvider | ||
| self.hideDurations = hideDurations | ||
| } | ||
|
|
||
| // MARK: - Layout Configuration | ||
|
|
||
| /// Grid configuration for legend items with adaptive sizing | ||
| private var columns: [GridItem] { | ||
| [GridItem(.adaptive( | ||
| minimum: SleepChartConstants.legendItemMinWidth, | ||
| maximum: SleepChartConstants.legendItemMaxWidth | ||
| ))] | ||
| private var oneRow: [GridItem] { | ||
| [ | ||
| GridItem(.adaptive(minimum: SleepChartConstants.legendItemMinWidth, | ||
| maximum: SleepChartConstants.legendItemMaxWidth) | ||
| ) | ||
| ] | ||
| } | ||
|
|
||
| private var twoRows: [GridItem] { | ||
| [ | ||
| GridItem(.adaptive(minimum: SleepChartConstants.legendItemMinWidth, | ||
| maximum: SleepChartConstants.legendItemMaxWidth), spacing: 0, alignment: .leading), | ||
|
|
||
| GridItem(.adaptive(minimum: SleepChartConstants.legendItemMinWidth, | ||
| maximum: SleepChartConstants.legendItemMaxWidth), spacing: 0, alignment: .leading) | ||
|
|
||
| ] | ||
| } | ||
|
|
||
| // MARK: - Body | ||
|
|
||
| public var body: some View { | ||
| LazyVGrid( | ||
| columns: columns, | ||
| alignment: .leading, | ||
| spacing: SleepChartConstants.legendItemSpacing | ||
| ) { | ||
|
|
||
| ViewThatFits { | ||
|
|
||
| LazyHGrid( | ||
| rows: oneRow, | ||
| alignment: .center, | ||
| spacing: SleepChartConstants.legendItemSpacing | ||
| ) { | ||
| legendItems | ||
| } | ||
|
|
||
| LazyHGrid( | ||
| rows: twoRows, | ||
| alignment: .center, | ||
| spacing: SleepChartConstants.legendItemSpacing | ||
| ) { | ||
| legendItems | ||
| } | ||
| } | ||
| } | ||
|
|
||
| // MARK: - Legend Items | ||
|
|
||
| @ViewBuilder | ||
| private var legendItems: some View { | ||
| ForEach(activeStages, id: \.self) { stage in | ||
| // Only show stages that have recorded time | ||
| if let duration = sleepData[stage], duration > 0 { | ||
| LegendItem( | ||
| stage: stage, | ||
| duration: duration, | ||
| duration: hideDurations ? nil : duration, | ||
| colorProvider: colorProvider, | ||
| durationFormatter: durationFormatter, | ||
| displayNameProvider: displayNameProvider | ||
| ) | ||
| } | ||
| } | ||
| } | ||
| } | ||
| } | ||
|
|
||
| /// A single legend item displaying a sleep stage with its color, name, and duration. | ||
| /// | ||
| /// This view shows a colored circle indicator, the stage name, and formatted duration | ||
| /// in a horizontal layout suitable for use in a legend grid. | ||
| private struct LegendItem: View { | ||
| public struct LegendItem: View { | ||
|
|
||
| // MARK: - Properties | ||
|
|
||
| /// The sleep stage this item represents | ||
| let stage: SleepStage | ||
|
|
||
| /// The total duration for this sleep stage | ||
| let duration: TimeInterval | ||
| let duration: TimeInterval? | ||
|
|
||
| /// Provider for the stage color | ||
| let colorProvider: SleepStageColorProvider | ||
|
|
@@ -78,10 +112,23 @@ private struct LegendItem: View { | |
| /// Provider for the stage display name | ||
| let displayNameProvider: SleepStageDisplayNameProvider | ||
|
|
||
| public init(stage: SleepStage, | ||
| duration: TimeInterval?, | ||
| colorProvider: SleepStageColorProvider = DefaultSleepStageColorProvider(), | ||
| durationFormatter: DurationFormatter = DefaultDurationFormatter(), | ||
| displayNameProvider: SleepStageDisplayNameProvider = DefaultSleepStageDisplayNameProvider()) { | ||
|
|
||
| self.stage = stage | ||
| self.duration = duration | ||
| self.colorProvider = colorProvider | ||
| self.durationFormatter = durationFormatter | ||
| self.displayNameProvider = displayNameProvider | ||
| } | ||
|
|
||
| // MARK: - Body | ||
|
|
||
| var body: some View { | ||
| HStack(spacing: SleepChartConstants.legendItemSpacing) { | ||
| public var body: some View { | ||
| HStack(spacing: 4) { | ||
| // Color indicator circle | ||
|
Comment on lines
+130
to
132
|
||
| Circle() | ||
| .fill(colorProvider.color(for: stage)) | ||
|
|
@@ -95,10 +142,13 @@ private struct LegendItem: View { | |
| .font(.caption) | ||
| .foregroundColor(.secondary) | ||
|
|
||
| // Duration | ||
| Text(durationFormatter.format(duration)) | ||
| .font(.caption.weight(.semibold)) | ||
| .foregroundColor(.primary) | ||
| if let duration { | ||
|
|
||
| // Duration | ||
| Text(durationFormatter.format(duration)) | ||
| .font(.caption.weight(.semibold)) | ||
| .foregroundColor(.primary) | ||
| } | ||
| } | ||
| } | ||
| } | ||
| } | ||
| Original file line number | Diff line number | Diff line change | ||||
|---|---|---|---|---|---|---|
|
|
@@ -137,7 +137,9 @@ public struct SleepTimelineGraph: View { | |||||
| renderStageConnector( | ||||||
| context: context, | ||||||
| from: prevRect, | ||||||
| to: currentRect | ||||||
| to: currentRect, | ||||||
| fromStage: previousStage, | ||||||
|
||||||
| fromStage: previousStage, | |
| fromStage: prevStage, |
Copilot
AI
Feb 2, 2026
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The linear gradient for the connector stroke uses controlPoint1/controlPoint2 as the gradient’s start/end points. That makes the gradient direction dependent on curve control points rather than the actual connector endpoints, which can look incorrect when stage durations/positions vary. Consider using startPoint and endPoint for the gradient vector so the color transition aligns with the connector’s start/end stages.
| with: .linearGradient(gradient, startPoint: controlPoint1, endPoint: controlPoint2), | |
| with: .linearGradient(gradient, startPoint: startPoint, endPoint: endPoint), |
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
|
|
@@ -38,10 +38,44 @@ public struct DefaultSleepStageColorProvider: SleepStageColorProvider { | |||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| case .asleepDeep: | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| return .indigo | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| case .asleepUnspecified: | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| return .purple | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| return Color(UIColor.lightGray) | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| return Color(UIColor.lightGray) | |
| return .gray |
Copilot
AI
Feb 2, 2026
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
CustomSleepStageColorProvider exposes a public API with inconsistent naming (awakeColour/remColour vs the rest of the codebase’s Color terminology) and a parameter label REM that breaks Swift API design guidelines (capitalized acronym as an external label is unusual). Consider renaming to awakeColor, remColor, etc., and using a rem: parameter label for consistency and discoverability.
| var awakeColour: Color | |
| var remColour: Color | |
| var coreColour: Color | |
| var deepColour: Color | |
| var unspecifiedColour: Color | |
| var inBedColour: Color | |
| public init(awake: Color? = nil, REM: Color? = nil, core: Color? = nil, deep: Color? = nil, unspecified: Color? = nil, inBed: Color? = nil) { | |
| self.awakeColour = awake ?? .orange | |
| self.remColour = REM ?? .cyan | |
| self.coreColour = core ?? .blue | |
| self.deepColour = deep ?? .indigo | |
| self.unspecifiedColour = unspecified ?? .purple | |
| self.inBedColour = inBed ?? .gray | |
| } | |
| public func color(for stage: SleepStage) -> Color { | |
| switch stage { | |
| case .awake: return awakeColour | |
| case .asleepREM: return remColour | |
| case .asleepCore: return coreColour | |
| case .asleepDeep: return deepColour | |
| case .asleepUnspecified: return unspecifiedColour | |
| case .inBed: return inBedColour | |
| var awakeColor: Color | |
| var remColor: Color | |
| var coreColor: Color | |
| var deepColor: Color | |
| var unspecifiedColor: Color | |
| var inBedColor: Color | |
| public init(awake: Color? = nil, rem: Color? = nil, core: Color? = nil, deep: Color? = nil, unspecified: Color? = nil, inBed: Color? = nil) { | |
| self.awakeColor = awake ?? .orange | |
| self.remColor = rem ?? .cyan | |
| self.coreColor = core ?? .blue | |
| self.deepColor = deep ?? .indigo | |
| self.unspecifiedColor = unspecified ?? .purple | |
| self.inBedColor = inBed ?? .gray | |
| } | |
| @available(*, deprecated, message: "Use init(awake:rem:core:deep:unspecified:inBed:) instead.") | |
| public init(awake: Color? = nil, REM: Color? = nil, core: Color? = nil, deep: Color? = nil, unspecified: Color? = nil, inBed: Color? = nil) { | |
| self.init( | |
| awake: awake, | |
| rem: REM, | |
| core: core, | |
| deep: deep, | |
| unspecified: unspecified, | |
| inBed: inBed | |
| ) | |
| } | |
| public func color(for stage: SleepStage) -> Color { | |
| switch stage { | |
| case .awake: return awakeColor | |
| case .asleepREM: return remColor | |
| case .asleepCore: return coreColor | |
| case .asleepDeep: return deepColor | |
| case .asleepUnspecified: return unspecifiedColor | |
| case .inBed: return inBedColor |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This PR bumps the minimum deployment targets (e.g., iOS 15 → 16, macOS 12 → 13). That’s a breaking change for package consumers; please confirm it’s required (e.g., for
ViewThatFits) and call it out explicitly in the PR description / release notes so downstream apps can plan accordingly.