From 13345bc7b2ed7d8f94c7af14b0affcb64cf6466f Mon Sep 17 00:00:00 2001 From: rannyeribaptist Date: Wed, 15 Jul 2026 21:37:53 -0300 Subject: [PATCH 1/2] Add State of Mind sync (iOS 18) One registry row + a serializer for HKStateOfMind: valence rides value under a "valence" unit; kind, valenceClassification, labels, and associations ride metadata as comma-joined names. Availability-gated to iOS 18 like breathing disturbances; wire schema documented in docs/PROTOCOL.md and pinned in the registry test. Co-Authored-By: Claude Fable 5 --- Pulso/Health/Serializers.swift | 120 +++++++++++++++++++++++++++++++ Pulso/Health/TypeRegistry.swift | 5 ++ PulsoTests/SerializerTests.swift | 1 + docs/PROTOCOL.md | 8 +++ 4 files changed, 134 insertions(+) diff --git a/Pulso/Health/Serializers.swift b/Pulso/Health/Serializers.swift index fe090d1..f272c4f 100644 --- a/Pulso/Health/Serializers.swift +++ b/Pulso/Health/Serializers.swift @@ -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 } diff --git a/Pulso/Health/TypeRegistry.swift b/Pulso/Health/TypeRegistry.swift index 2b0c586..c0afaf1 100644 --- a/Pulso/Health/TypeRegistry.swift +++ b/Pulso/Health/TypeRegistry.swift @@ -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( diff --git a/PulsoTests/SerializerTests.swift b/PulsoTests/SerializerTests.swift index 4a924e9..1548919 100644 --- a/PulsoTests/SerializerTests.swift +++ b/PulsoTests/SerializerTests.swift @@ -233,6 +233,7 @@ final class SerializerTests: XCTestCase { ] if #available(iOS 18.0, *) { expected.append("appleSleepingBreathingDisturbances") + expected.append("stateOfMind") } expected += [ "appleStandHour", "mindfulSession", diff --git a/docs/PROTOCOL.md b/docs/PROTOCOL.md index 952d71a..e5d5008 100644 --- a/docs/PROTOCOL.md +++ b/docs/PROTOCOL.md @@ -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_` / +`association_` / `kind_` / `classification_`. **Workouts** From 589505232534f51252bc6fc8df99f7ad86909c79 Mon Sep 17 00:00:00 2001 From: rannyeribaptist Date: Wed, 15 Jul 2026 21:37:53 -0300 Subject: [PATCH 2/2] Request newly needed Health permissions on scene activation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When the registry grows a type, statusForAuthorizationRequest flips to shouldRequest but the ask waited for a tap on the StatusView banner — easy to miss after an update. Ask as soon as the scene activates instead; the banner stays as the manual fallback. Co-Authored-By: Claude Fable 5 --- Pulso/App/AppServices.swift | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/Pulso/App/AppServices.swift b/Pulso/App/AppServices.swift index 7071d96..2eb9d02 100644 --- a/Pulso/App/AppServices.swift +++ b/Pulso/App/AppServices.swift @@ -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()