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
9 changes: 8 additions & 1 deletion Pulso/App/AppServices.swift
Original file line number Diff line number Diff line change
Expand Up @@ -79,7 +79,14 @@ final class AppServices {
case .active:
Task {
await refreshAuthStatus()
await syncNow(.foreground)
if status.authNeeded {
// New types join readTypes as the registry grows; ask as
// soon as HealthKit reports something to ask rather than
// waiting for a tap on the StatusView banner.
await requestHealthAccess() // asks, re-checks, then syncs
} else {
await syncNow(.foreground)
}
}
case .background:
hub.scheduleNextRefresh()
Expand Down
120 changes: 120 additions & 0 deletions Pulso/Health/Serializers.swift
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,126 @@ enum Serializers {
}
}

// MARK: - State of Mind (iOS 18+)

/// HKStateOfMind is its own HKSample subclass (neither category nor
/// quantity): a valence in [-1, +1] plus a kind, discrete emotion labels,
/// and life-area associations. Valence rides `value`; the enums ride
/// metadata as comma-joined names so the wire format stays scalar.
@available(iOS 18.0, *)
@Sendable
static func stateOfMind(_ sample: HKSample, context: SerializerContext) -> SampleDTO? {
guard let mood = sample as? HKStateOfMind else { return nil }
var dto = base(mood, type: "stateOfMind", context: context)
dto.value = .number(mood.valence)
dto.unit = "valence"
var metadata = dto.metadata ?? [:]
metadata["kind"] = .string(stateOfMindKindName(mood.kind))
metadata["valenceClassification"] = .string(valenceClassificationName(mood.valenceClassification))
if !mood.labels.isEmpty {
metadata["labels"] = .string(mood.labels.map(stateOfMindLabelName).joined(separator: ","))
}
if !mood.associations.isEmpty {
metadata["associations"] = .string(
mood.associations.map(stateOfMindAssociationName).joined(separator: ","))
}
dto.metadata = metadata
return dto
}

@available(iOS 18.0, *)
static func stateOfMindKindName(_ kind: HKStateOfMind.Kind) -> String {
switch kind {
case .momentaryEmotion: "momentaryEmotion"
case .dailyMood: "dailyMood"
@unknown default: "kind_\(kind.rawValue)"
}
}

@available(iOS 18.0, *)
static func valenceClassificationName(_ c: HKStateOfMind.ValenceClassification) -> String {
switch c {
case .veryUnpleasant: "veryUnpleasant"
case .unpleasant: "unpleasant"
case .slightlyUnpleasant: "slightlyUnpleasant"
case .neutral: "neutral"
case .slightlyPleasant: "slightlyPleasant"
case .pleasant: "pleasant"
case .veryPleasant: "veryPleasant"
@unknown default: "classification_\(c.rawValue)"
}
}

@available(iOS 18.0, *)
static func stateOfMindLabelName(_ label: HKStateOfMind.Label) -> String {
switch label {
case .amazed: "amazed"
case .amused: "amused"
case .angry: "angry"
case .annoyed: "annoyed"
case .anxious: "anxious"
case .ashamed: "ashamed"
case .brave: "brave"
case .calm: "calm"
case .confident: "confident"
case .content: "content"
case .disappointed: "disappointed"
case .discouraged: "discouraged"
case .disgusted: "disgusted"
case .drained: "drained"
case .embarrassed: "embarrassed"
case .excited: "excited"
case .frustrated: "frustrated"
case .grateful: "grateful"
case .guilty: "guilty"
case .happy: "happy"
case .hopeful: "hopeful"
case .hopeless: "hopeless"
case .indifferent: "indifferent"
case .irritated: "irritated"
case .jealous: "jealous"
case .joyful: "joyful"
case .lonely: "lonely"
case .overwhelmed: "overwhelmed"
case .passionate: "passionate"
case .peaceful: "peaceful"
case .proud: "proud"
case .relieved: "relieved"
case .sad: "sad"
case .satisfied: "satisfied"
case .scared: "scared"
case .stressed: "stressed"
case .surprised: "surprised"
case .worried: "worried"
@unknown default: "label_\(label.rawValue)"
}
}

@available(iOS 18.0, *)
static func stateOfMindAssociationName(_ a: HKStateOfMind.Association) -> String {
switch a {
case .community: "community"
case .currentEvents: "currentEvents"
case .dating: "dating"
case .education: "education"
case .family: "family"
case .fitness: "fitness"
case .friends: "friends"
case .health: "health"
case .hobbies: "hobbies"
case .identity: "identity"
case .money: "money"
case .partner: "partner"
case .selfCare: "selfCare"
case .spirituality: "spirituality"
case .tasks: "tasks"
case .travel: "travel"
case .weather: "weather"
case .work: "work"
@unknown default: "association_\(a.rawValue)"
}
}

@Sendable
static func workout(_ sample: HKSample, context: SerializerContext) -> SampleDTO? {
guard let workout = sample as? HKWorkout else { return nil }
Expand Down
5 changes: 5 additions & 0 deletions Pulso/Health/TypeRegistry.swift
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,11 @@ enum TypeRegistry {
types.append(quantity(.appleSleepingBreathingDisturbances, key: "appleSleepingBreathingDisturbances",
name: "Breathing Disturbances", group: Group.sleep,
unit: .count(), label: "count"))
types.append(SyncedType(
key: "stateOfMind", displayName: "State of Mind", group: Group.sleep,
sampleType: HKObjectType.stateOfMindType(),
frequency: .hourly, serialize: Serializers.stateOfMind
))
}
types += [
SyncedType(
Expand Down
1 change: 1 addition & 0 deletions PulsoTests/SerializerTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -233,6 +233,7 @@ final class SerializerTests: XCTestCase {
]
if #available(iOS 18.0, *) {
expected.append("appleSleepingBreathingDisturbances")
expected.append("stateOfMind")
}
expected += [
"appleStandHour", "mindfulSession",
Expand Down
8 changes: 8 additions & 0 deletions docs/PROTOCOL.md
Original file line number Diff line number Diff line change
Expand Up @@ -113,6 +113,14 @@ Percent-unit types deliver human-scale values: `97` under unit `"%"` means
| `appleSleepingBreathingDisturbances` | number | `count` |
| `appleStandHour` | `stood`, `idle` | — |
| `mindfulSession` | — (interval is the data) | — |
| `stateOfMind` | number: valence in [−1, +1] | `valence` |

`stateOfMind` (iOS 18+) additionally carries in `metadata`: `kind`
(`momentaryEmotion` \| `dailyMood`), `valenceClassification`
(`veryUnpleasant` … `veryPleasant`), and, when present, `labels` and
`associations` as comma-joined name lists (e.g. `"happy,grateful"`,
`"family,work"`). Unknown future enum cases surface as `label_<n>` /
`association_<n>` / `kind_<n>` / `classification_<n>`.

**Workouts**

Expand Down