Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions HomeAssistant.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -1597,9 +1597,9 @@
Sensors/Health/HealthSensorListView.swift,
Sensors/Health/HealthSensorListViewModel.swift,
Sensors/Health/HealthSensorRow.swift,
Sensors/List/SensorForegroundOnlyBadge.swift,
Sensors/List/SensorListView.swift,
Sensors/List/SensorListViewModel.swift,
Sensors/List/SensorNoteBadge.swift,
Sensors/List/SensorRow.swift,
Sensors/Permissions/BluetoothAuthorizationRequester.swift,
Sensors/Permissions/SensorPermission.swift,
Expand Down Expand Up @@ -1835,9 +1835,9 @@
Sensors/Health/HealthSensorListView.swift,
Sensors/Health/HealthSensorListViewModel.swift,
Sensors/Health/HealthSensorRow.swift,
Sensors/List/SensorForegroundOnlyBadge.swift,
Sensors/List/SensorListView.swift,
Sensors/List/SensorListViewModel.swift,
Sensors/List/SensorNoteBadge.swift,
Sensors/List/SensorRow.swift,
Sensors/Permissions/BluetoothAuthorizationRequester.swift,
Sensors/Permissions/SensorPermission.swift,
Expand Down
3 changes: 3 additions & 0 deletions Sources/App/Resources/en.lproj/Localizable.strings
Original file line number Diff line number Diff line change
Expand Up @@ -2388,6 +2388,7 @@ The server only answers while the app is open, and stops when you leave it.";
"settings_sensors.health.footer" = "Apple Health sensors use the existing per-sensor controls below. Health data is read only during normal sensor updates.";
"settings_sensors.health.header" = "Apple Health";
"settings_sensors.health.metric.in_bed.footer" = "Apple Watch records sleep stages but not time in bed, so this stays unavailable until a source writes it. Turn on “Track Time in Bed with iPhone” in Health > Sleep > Options, or use a sleep tracking app that records time in bed.";
"settings_sensors.health.metric.move_goal.footer" = "Reported in kilocalories, so this stays unavailable while the Move ring is set to move time.";
"settings_sensors.health.reload" = "Reload Sensors";
"settings_sensors.health.request_access" = "Request Apple Health Access";
"settings_sensors.health.sensors.enable_all" = "Enable all Apple Health sensors";
Expand Down Expand Up @@ -2442,6 +2443,8 @@ The server only answers while the app is open, and stops when you leave it.";
"settings_sensors.sensors.foreground_only.badge" = "When app is open";
"settings_sensors.sensors.header" = "Sensors";
"settings_sensors.sensors.no_results" = "No sensors match your search";
"settings_sensors.sensors.requires_apple_watch.accessibility_label" = "Only reports with a paired Apple Watch";
"settings_sensors.sensors.requires_apple_watch.badge" = "Requires Apple Watch";
"settings_sensors.sensors.search_prompt" = "Search sensors";
"settings_sensors.sensors.unavailable_on_device.header" = "Unavailable on this device";
"settings_sensors.sensors.unavailable_on_device.state" = "Unavailable";
Expand Down
18 changes: 14 additions & 4 deletions Sources/App/Settings/Sensors/Health/HealthSensorRow.swift
Original file line number Diff line number Diff line change
Expand Up @@ -29,10 +29,17 @@ struct HealthSensorRow: View {
VStack(alignment: .leading) {
Text(metric.name)
.foregroundColor(isEnabled ? .primary : .secondary)
if let subtitle {
Text(subtitle)
.foregroundColor(.secondary)
.font(.subheadline)
VStack(alignment: .leading, spacing: DesignSystem.Spaces.half) {
if let subtitle {
Text(subtitle)
.foregroundColor(.secondary)
.font(.subheadline)
}
// A fact about the sensor rather than a warning, so it stays even while
// the sensor is off: it says why the row might read unavailable once on.
if metric.requiresAppleWatch {
SensorNoteBadge.requiresAppleWatch
}
}
}
}
Expand All @@ -55,6 +62,9 @@ struct HealthSensorRow: View {
if let inBed = HealthKitMetric.metric(uniqueID: "health_sleep_in_bed") {
HealthSensorRow(metric: inBed, stateDescription: "unavailable", isEnabled: .constant(true))
}
if let standHours = HealthKitMetric.metric(uniqueID: "health_apple_stand_hours") {
HealthSensorRow(metric: standHours, stateDescription: "10 hours", isEnabled: .constant(true))
}
}
}
#endif
32 changes: 0 additions & 32 deletions Sources/App/Settings/Sensors/List/SensorForegroundOnlyBadge.swift

This file was deleted.

60 changes: 60 additions & 0 deletions Sources/App/Settings/Sensors/List/SensorNoteBadge.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
import Shared
import SwiftUI

/// A quiet capsule under a sensor's state stating a fact about the sensor, such as that it only
/// reports while the app is open, or only with an Apple Watch.
///
/// Quiet rather than coloured: it qualifies the state above it, it isn't a warning.
struct SensorNoteBadge: View {
let text: String
let accessibilityLabel: String

/// For a sensor that only reports while the app is open on screen.
static var foregroundOnly: SensorNoteBadge {
SensorNoteBadge(
text: L10n.SettingsSensors.Sensors.ForegroundOnly.badge,
accessibilityLabel: L10n.SettingsSensors.Sensors.ForegroundOnly.accessibilityLabel
)
}

/// For a metric only an Apple Watch records, which reads as unavailable without one.
static var requiresAppleWatch: SensorNoteBadge {
SensorNoteBadge(
text: L10n.SettingsSensors.Sensors.RequiresAppleWatch.badge,
accessibilityLabel: L10n.SettingsSensors.Sensors.RequiresAppleWatch.accessibilityLabel
)
}

var body: some View {
Text(text)
.font(.caption2)
.foregroundStyle(.secondary)
.padding(.horizontal, DesignSystem.Spaces.one)
.padding(.vertical, DesignSystem.Spaces.micro)
.background(Color(uiColor: .tertiarySystemFill), in: Capsule())
.accessibilityLabel(accessibilityLabel)
}
}

#Preview {
List {
VStack(alignment: .leading) {
Text("Camera Motion")
VStack(alignment: .leading, spacing: DesignSystem.Spaces.half) {
Text("false")
.foregroundColor(.secondary)
.font(.subheadline)
SensorNoteBadge.foregroundOnly
}
}
VStack(alignment: .leading) {
Text("Stand Hours")
VStack(alignment: .leading, spacing: DesignSystem.Spaces.half) {
Text("10 hours")
.foregroundColor(.secondary)
.font(.subheadline)
SensorNoteBadge.requiresAppleWatch
}
}
}
}
2 changes: 1 addition & 1 deletion Sources/App/Settings/Sensors/List/SensorRow.swift
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@ struct SensorRow: View {
// Some sensors cannot be kept current once the app leaves the screen, so the
// row says so rather than letting a frozen state look like a failure.
if isAvailable, SensorForegroundAvailability.isForegroundOnly(sensorUniqueID: sensor.UniqueID) {
SensorForegroundOnlyBadge()
SensorNoteBadge.foregroundOnly
}
}
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
#if os(iOS) && !targetEnvironment(macCatalyst)
import Foundation
import HealthKit

public extension HealthKitActivityRing {
/// The sample type whose new samples move this ring. An activity summary can't be observed or
/// delivered in the background itself, so this stands in for it.
var progressSampleType: HKSampleType {
switch self {
case .move: return HKQuantityType(.activeEnergyBurned)
case .exercise: return HKQuantityType(.appleExerciseTime)
case .stand: return HKCategoryType(.appleStandHour)
}
}

func progress(in summary: HKActivitySummary) -> HKQuantity {
switch self {
case .move: return summary.activeEnergyBurned
case .exercise: return summary.appleExerciseTime
case .stand: return summary.appleStandHours
}
}

/// `nil` for an Exercise or Stand ring with no goal, which is what an iPhone without an Apple Watch
/// reports. Move always has an energy goal, even in move-time mode, where it isn't the ring's.
func goal(in summary: HKActivitySummary) -> HKQuantity? {
switch self {
case .move: return summary.activeEnergyBurnedGoal
case .exercise: return summary.exerciseTimeGoal
case .stand: return summary.standHoursGoal
}
}
}
#endif
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
#if os(iOS) && !targetEnvironment(macCatalyst)
import Foundation

/// One of the three rings the Fitness app draws. Kept free of `HealthKit` types so the metric catalog
/// stays plain data; `HealthKitActivityRing+HealthKit` reads a ring out of an `HKActivitySummary`.
public enum HealthKitActivityRing: String, CaseIterable, Sendable {
/// Active energy burned, in kilocalories.
case move
/// Exercise minutes.
case exercise
/// Hours with at least a minute spent standing, or rolling for wheelchair users.
case stand
}
#endif
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
#if os(iOS) && !targetEnvironment(macCatalyst)
import Foundation
import HealthKit

public extension HealthKitActivitySummaryField {
/// The raw `HKActivitySummaryType` identifier, which every activity ring metric reads from.
static let identifier = "HKActivitySummaryTypeIdentifier"

/// What to observe for background delivery of this field: the ring's sample type for its progress,
/// nothing for a goal. Changing a goal in Fitness writes no sample, so there is nothing to observe;
/// goals pick up their new value on the next periodic update, or on the next delivery for a
/// progress sensor the user also switched on. Observing the progress type for a goal would only
/// wake the app to re-read a constant, and put a type the user didn't choose on the permission
/// sheet.
var observedSampleTypes: [HKSampleType] {
switch self {
case let .progress(ring): return [ring.progressSampleType]
case .goal: return []
}
}

/// This field of `summary` in `unit`, or `nil` when the ring isn't being tracked.
///
/// A ring nobody is tracking — Exercise and Stand without an Apple Watch — has no goal, and its
/// progress is then a zero that was never measured. A Move ring set to move time rather than
/// energy, the mode Fitness offers younger users and wheelchair users, may still carry energy
/// numbers, but they aren't the ring. None of that is reported: an untracked ring and its goal
/// both stay unavailable.
func value(in summary: HKActivitySummary, unit: HKUnit) -> Double? {
let progress = ring.progress(in: summary)
guard let goal = ring.goal(in: summary),
goal.is(compatibleWith: unit), progress.is(compatibleWith: unit),
isTracked(in: summary, goal: goal, unit: unit) else {
return nil
}

switch self {
case .progress: return progress.doubleValue(for: unit)
case .goal: return goal.doubleValue(for: unit)
}
}

/// `goal` must already be known to be compatible with `unit`. Exercise and Stand are tracked
/// whenever they have a goal at all; Move's goal is always present, so it has to be checked.
private func isTracked(in summary: HKActivitySummary, goal: HKQuantity, unit: HKUnit) -> Bool {
guard ring == .move else {
return true
}
return summary.activityMoveMode == .activeEnergy && goal.doubleValue(for: unit) > 0
}
}
#endif
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
#if os(iOS) && !targetEnvironment(macCatalyst)
import Foundation

/// One number of a day's `HKActivitySummary`: how far a ring has got, or where it closes.
public enum HealthKitActivitySummaryField: Equatable, Hashable, Sendable {
case progress(HealthKitActivityRing)
case goal(HealthKitActivityRing)

public var ring: HealthKitActivityRing {
switch self {
case let .progress(ring), let .goal(ring): return ring
}
}

public var isGoal: Bool {
if case .goal = self {
return true
}
return false
}
}
#endif
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,76 @@ public extension HealthKitMetric {
decimalPlaces: 1,
availableFromIOS: 11
),
] + activityRingMetrics

/// What the Fitness app's rings add to the samples above: the daily goals, and stand hours, which
/// have no quantity type to sum. All are read from the day's activity summary. Move and Exercise
/// progress are "Active Energy" and "Exercise Time" above, and the goal IDs extend theirs; the
/// goals keep the names Fitness gives them, so "Move Goal" is the goal of "Active Energy".
static let activityRingMetrics: [HealthKitMetric] = [
ringMetric(
.goal(.move),
uniqueID: "health_active_energy_burned_goal",
name: "Move Goal",
footer: .moveGoalEnergyOnly
),
ringMetric(
.goal(.exercise),
uniqueID: "health_apple_exercise_time_goal",
name: "Exercise Goal",
requiresAppleWatch: true
),
ringMetric(
.progress(.stand),
uniqueID: "health_apple_stand_hours",
name: "Stand Hours",
icon: "mdi:human-handsup",
requiresAppleWatch: true
),
ringMetric(
.goal(.stand),
uniqueID: "health_apple_stand_hours_goal",
name: "Stand Goal",
requiresAppleWatch: true
),
]

private static func ringMetric(
_ field: HealthKitActivitySummaryField,
uniqueID: String,
name: String,
icon: String = "mdi:bullseye-arrow",
footer: HealthKitMetricFooter? = nil,
requiresAppleWatch: Bool = false
) -> HealthKitMetric {
let unit: String
let queryUnit: HealthKitMetricUnit
let deviceClass: DeviceClass?
switch field.ring {
case .move: (unit, queryUnit, deviceClass) = ("kcal", .kilocalorie, nil)
case .exercise: (unit, queryUnit, deviceClass) = ("min", .minute, .duration)
// A count of the hours stood in, not a length of time, so no duration device class: 7 of 12
// shouldn't be offered in minutes.
case .stand: (unit, queryUnit, deviceClass) = ("hours", .count, nil)
}

return HealthKitMetric(
uniqueID: uniqueID,
identifier: HealthKitActivitySummaryField.identifier,
name: name,
icon: icon,
footer: footer,
requiresAppleWatch: requiresAppleWatch,
unit: unit,
queryUnit: queryUnit,
aggregation: .activitySummary(field),
category: .activity,
deviceClass: deviceClass,
// Progress resets with the day like the other daily totals; a goal is a setting, and one
// that can be lowered.
stateClass: field.isGoal ? .measurement : .totalIncreasing,
decimalPlaces: 0
)
}
}
#endif
Loading
Loading