From c6f111920b4dc2b4f6197ee1309fdd23c6027aa7 Mon Sep 17 00:00:00 2001 From: Jonathan Ng Date: Thu, 22 Oct 2026 19:45:53 -0700 Subject: [PATCH 1/2] feat: add a watchOS app that records overnight wrist movement and sends a summary to the iPhone Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_016Wac1uHSpomCEBtwBdw1YA Signed-off-by: Jonathan Ng --- .swiftlint.yml | 2 + Shared/WatchMotion/WatchMotionFile.swift | 152 ++++++++++++++++ Sleepypod.xcodeproj/project.pbxproj | 163 +++++++++++++++++- Sleepypod/Services/WatchMotionReceiver.swift | 88 ++++++++++ Sleepypod/SleepypodApp.swift | 3 + Sleepypod/Views/Settings/SettingsSheet.swift | 3 + .../Views/Settings/WatchMotionView.swift | 37 ++++ SleepypodTests/WatchMotionTests.swift | 133 ++++++++++++++ SleepypodWatch/MotionRecorder.swift | 48 ++++++ SleepypodWatch/RecordingController.swift | 80 +++++++++ SleepypodWatch/SleepypodWatchApp.swift | 43 +++++ docs/watch-motion-format.md | 47 +++++ project.yml | 23 +++ 13 files changed, 820 insertions(+), 2 deletions(-) create mode 100644 Shared/WatchMotion/WatchMotionFile.swift create mode 100644 Sleepypod/Services/WatchMotionReceiver.swift create mode 100644 Sleepypod/Views/Settings/WatchMotionView.swift create mode 100644 SleepypodTests/WatchMotionTests.swift create mode 100644 SleepypodWatch/MotionRecorder.swift create mode 100644 SleepypodWatch/RecordingController.swift create mode 100644 SleepypodWatch/SleepypodWatchApp.swift create mode 100644 docs/watch-motion-format.md diff --git a/.swiftlint.yml b/.swiftlint.yml index fb197040..3fb2107a 100644 --- a/.swiftlint.yml +++ b/.swiftlint.yml @@ -1,5 +1,7 @@ included: - Sleepypod + - SleepypodWatch + - Shared excluded: - contract-tests diff --git a/Shared/WatchMotion/WatchMotionFile.swift b/Shared/WatchMotion/WatchMotionFile.swift new file mode 100644 index 00000000..d8c1d8ad --- /dev/null +++ b/Shared/WatchMotion/WatchMotionFile.swift @@ -0,0 +1,152 @@ +import Foundation + +/// One accelerometer reading from the Watch: Unix seconds and acceleration in g, gravity included. +struct MotionSample: Sendable, Equatable { + var time: Double + var x: Double + var y: Double + var z: Double + + var magnitude: Double { (x * x + y * y + z * z).squareRoot() } +} + +/// Fixed-width summaries of a night's wrist acceleration, small enough to send to the iPhone and used by +/// sleepypod/training as movement labels. Format documented in docs/watch-motion-format.md. +struct WatchMotionFile: Codable, Sendable, Equatable { + static let currentVersion = "watch-motion-v1" + + var version = WatchMotionFile.currentVersion + var recordedFromMs: Int64 + var recordedToMs: Int64 + var timeZone: String + var utcOffsetSeconds: Int + var sampleRateHz: Double + var binSeconds: Double + var jerkThresholdGPerSecond: Double + var device: String + var bins: Bins + /// Raw readings as [Unix ms, x, y, z]; nil unless raw export was requested. + var raw: [[Double]]? + + /// Columnar so a night of 2 s bins stays a few hundred kilobytes of JSON. + struct Bins: Codable, Sendable, Equatable { + var startMs: [Int64] = [] + var samples: [Int] = [] + var meanMagnitude: [Double] = [] + var magnitudeVariance: [Double] = [] + var maxJerk: [Double] = [] + var movingSamples: [Int] = [] + + var count: Int { startMs.count } + } + + enum FormatError: Error, Equatable { + case unsupportedVersion(String) + case mismatchedColumns + case badInterval + } + + func encoded() throws -> Data { + let encoder = JSONEncoder() + encoder.outputFormatting = [.sortedKeys] + return try encoder.encode(self) + } + + static func decode(_ data: Data) throws -> WatchMotionFile { + let file = try JSONDecoder().decode(WatchMotionFile.self, from: data) + try file.validate() + return file + } + + func validate() throws { + guard version == Self.currentVersion else { throw FormatError.unsupportedVersion(version) } + guard recordedToMs >= recordedFromMs, binSeconds > 0 else { throw FormatError.badInterval } + let n = bins.count + guard [bins.samples.count, bins.meanMagnitude.count, bins.magnitudeVariance.count, + bins.maxJerk.count, bins.movingSamples.count].allSatisfy({ $0 == n }) else { + throw FormatError.mismatchedColumns + } + } + + /// `watch-motion-.json`, so files sort by night and never collide. + var fileName: String { "watch-motion-\(recordedFromMs).json" } +} + +enum MotionSummarizer { + static let defaultBinSeconds = 2.0 + /// Rate of change of |a| above which a reading counts as moving. Wrist tremor and breathing stay well below it. + static let defaultJerkThreshold = 1.0 + + /// Bins aligned to Unix time, so bins from different nights and devices line up. Bins with no readings are + /// omitted: a missing bin means no data, never stillness. Jerk is measured only between consecutive readings + /// inside one bin and at most `maxGap` apart. + static func summarize(_ samples: [MotionSample], binSeconds: Double = defaultBinSeconds, + jerkThreshold: Double = defaultJerkThreshold, maxGap: Double = 0.5) -> WatchMotionFile.Bins { + var bins = WatchMotionFile.Bins() + let sorted = samples.sorted { $0.time < $1.time } + var index = 0 + while index < sorted.count { + let binStart = (sorted[index].time / binSeconds).rounded(.down) * binSeconds + let binEnd = binStart + binSeconds + var magnitudes: [Double] = [] + var maxJerk = 0.0 + var moving = 0 + var previous: MotionSample? + while index < sorted.count, sorted[index].time < binEnd { + let sample = sorted[index] + if let previous, sample.time > previous.time, sample.time - previous.time <= maxGap { + let jerk = abs(sample.magnitude - previous.magnitude) / (sample.time - previous.time) + maxJerk = max(maxJerk, jerk) + if jerk > jerkThreshold { moving += 1 } + } + magnitudes.append(sample.magnitude) + previous = sample + index += 1 + } + let mean = magnitudes.reduce(0, +) / Double(magnitudes.count) + let variance = magnitudes.reduce(0) { $0 + ($1 - mean) * ($1 - mean) } / Double(magnitudes.count) + bins.startMs.append(Int64((binStart * 1000).rounded())) + bins.samples.append(magnitudes.count) + bins.meanMagnitude.append(mean) + bins.magnitudeVariance.append(variance) + bins.maxJerk.append(maxJerk) + bins.movingSamples.append(moving) + } + return bins + } + + static func file(samples: [MotionSample], from: Date, to: Date, timeZone: TimeZone, device: String, + sampleRateHz: Double = 50, binSeconds: Double = defaultBinSeconds, + jerkThreshold: Double = defaultJerkThreshold, includeRaw: Bool = false) -> WatchMotionFile { + WatchMotionFile( + recordedFromMs: Int64((from.timeIntervalSince1970 * 1000).rounded()), + recordedToMs: Int64((to.timeIntervalSince1970 * 1000).rounded()), + timeZone: timeZone.identifier, + utcOffsetSeconds: timeZone.secondsFromGMT(for: from), + sampleRateHz: sampleRateHz, binSeconds: binSeconds, jerkThresholdGPerSecond: jerkThreshold, + device: device, + bins: summarize(samples, binSeconds: binSeconds, jerkThreshold: jerkThreshold), + raw: includeRaw ? samples.map { [($0.time * 1000).rounded(), $0.x, $0.y, $0.z] } : nil) + } +} + +/// When and how long to ask the Watch to record. Apple's recorder takes at most 12 hours per request. +struct RecordingPlan: Codable, Sendable, Equatable { + static let maximumDuration: TimeInterval = 12 * 3600 + + var start: Date + var duration: TimeInterval + + init(start: Date, hours: Double) { + self.start = start + duration = min(max(hours, 0.5) * 3600, Self.maximumDuration) + } + + var end: Date { start.addingTimeInterval(duration) } + + /// The span that can be read back now: from the start until now or the planned end, whichever is first. + func readableInterval(now: Date) -> DateInterval? { + let stop = min(now, end) + return stop > start ? DateInterval(start: start, end: stop) : nil + } +} diff --git a/Sleepypod.xcodeproj/project.pbxproj b/Sleepypod.xcodeproj/project.pbxproj index ae2b12da..2f450845 100644 --- a/Sleepypod.xcodeproj/project.pbxproj +++ b/Sleepypod.xcodeproj/project.pbxproj @@ -19,6 +19,7 @@ 0E1D5B93D3953CAFF96C82E2 /* ThermalBedView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2BE7EB6DCF180386BD8174D1 /* ThermalBedView.swift */; }; 1201459B0B50A7E1250D248B /* openapi.json in Resources */ = {isa = PBXBuildFile; fileRef = B881147C7D2F0ADEBAD1AD02 /* openapi.json */; }; 14213FE824632EF617773E36 /* IBMPlexMono-Medium.ttf in Resources */ = {isa = PBXBuildFile; fileRef = 5C8D4D424A4CEC3378E85D58 /* IBMPlexMono-Medium.ttf */; }; + 148DFAE07760ED2F88A712F5 /* MotionRecorder.swift in Sources */ = {isa = PBXBuildFile; fileRef = 25144B52B22AB54483983387 /* MotionRecorder.swift */; }; 1558A064C84FC3F65897D129 /* TemperatureConversion.swift in Sources */ = {isa = PBXBuildFile; fileRef = 30D386CF79858C76A30F9B9E /* TemperatureConversion.swift */; }; 170ABA1BFA94168BFBC36E28 /* HealthSyncService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 58B707F5F934F822B7D3D2C1 /* HealthSyncService.swift */; }; 178824588D3CC952F0517CAC /* SleepRecord.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2F0AFD07A8CB0082733ACB36 /* SleepRecord.swift */; }; @@ -26,6 +27,7 @@ 1A9A73E1A4CB9DA72F492877 /* HapticsTestView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 71D9F9BB5117870F21D58DE2 /* HapticsTestView.swift */; }; 1B0058BB0B3DBF7669A2AF6E /* SleepCurve.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5EB30F6B5F7F58639454A71E /* SleepCurve.swift */; }; 1C1212D4AE1E9773A795BCD8 /* PresenceHeatmapView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E6BD6BAE3E54FDA3CB065EAA /* PresenceHeatmapView.swift */; }; + 1D47C1C23CC1F89056477831 /* WatchMotionView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9AA7286021C48AD5A6B7E3D9 /* WatchMotionView.swift */; }; 1F1DB7FE924C1EC257BE8FE1 /* VitalsRecord.swift in Sources */ = {isa = PBXBuildFile; fileRef = B7C94AEB5A7EF2FE53231869 /* VitalsRecord.swift */; }; 22F4C7CA1D9F91A639905C8B /* HealthScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0370A4FF64B61475AC0C1125 /* HealthScreen.swift */; }; 2312033184B39DF0FD083920 /* SleepAnalyzer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7609963B2396D197BA589466 /* SleepAnalyzer.swift */; }; @@ -45,13 +47,17 @@ 447C11C4377FF915603C4FC3 /* StageComparison.swift in Sources */ = {isa = PBXBuildFile; fileRef = EAC584FE4474921B48DF9078 /* StageComparison.swift */; }; 4537E75A1A5822042DB9C8A2 /* Log.swift in Sources */ = {isa = PBXBuildFile; fileRef = 060906F2618180E4E45E29F3 /* Log.swift */; }; 47254DC4322FC4F3DE0DA3EC /* BetaModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7398AC2DDAE6E48A52F7C413 /* BetaModels.swift */; }; + 4AF19000D28A42DA513268E0 /* RecordingController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7A6FF64E72F4ED36D4A05386 /* RecordingController.swift */; }; 4B76AE90EF4105BC911F5102 /* ScheduleManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = A0EF147CC5E285EB9FCF9DBB /* ScheduleManager.swift */; }; 4C246C2258249AC5125AADAC /* StageChallenger.swift in Sources */ = {isa = PBXBuildFile; fileRef = F3313CC2AC5F797597D9D9D4 /* StageChallenger.swift */; }; + 4F5BEB28BF7E5F3C71CF3E31 /* WatchMotionFile.swift in Sources */ = {isa = PBXBuildFile; fileRef = F63D97596C1E062CB7C4917A /* WatchMotionFile.swift */; }; 53581400686FE861A24087AE /* CalibrationTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = DE0553DF9686966CDE7AF58B /* CalibrationTests.swift */; }; + 539F5AFD46F4BBFC1BB1EDC3 /* WatchMotionFile.swift in Sources */ = {isa = PBXBuildFile; fileRef = F63D97596C1E062CB7C4917A /* WatchMotionFile.swift */; }; 56087F2DD514F7DAFD5F4C60 /* RangeSlider.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2B3120820F4ED5A63AAF30A6 /* RangeSlider.swift */; }; 59D63C6FB621C08C2D068AA7 /* NotificationRelay.swift in Sources */ = {isa = PBXBuildFile; fileRef = D3B44F02B272682A83840CA4 /* NotificationRelay.swift */; }; 5C3DD83F9E27810788A9C7A1 /* TemperatureCurve.swift in Sources */ = {isa = PBXBuildFile; fileRef = E0F14C7A4B6C837B173C5EFA /* TemperatureCurve.swift */; }; 5D6566441668D7E70CE84F29 /* FreeSleepClient.swift in Sources */ = {isa = PBXBuildFile; fileRef = 970CD9734D4BC3ACBE225328 /* FreeSleepClient.swift */; }; + 5F52E018DFF7AA79E98AB53E /* SleepypodWatchApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = A182F7D23DCC1F6F92576932 /* SleepypodWatchApp.swift */; }; 5F58632442748EC2A7862CB1 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 9E902CB9201C2AE527A6C49F /* Assets.xcassets */; }; 5F7EBA60B0C02EBF2B9C8770 /* StageLanesTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4FF755316550B89052F80058 /* StageLanesTests.swift */; }; 61223F858260C7EB346D5602 /* TempBackdrop.swift in Sources */ = {isa = PBXBuildFile; fileRef = CCB9B2C20054DF2DEFE406E5 /* TempBackdrop.swift */; }; @@ -120,14 +126,17 @@ BAF1104FA0A43D228909155C /* HealthBackgroundSyncTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2BC5C59E43F3180AD07E5304 /* HealthBackgroundSyncTests.swift */; }; BC37EEEE2AD3A4072D8141B0 /* InternetToggleTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = AC70CD7C5F0C026275B7EFF6 /* InternetToggleTests.swift */; }; BFB6082091FA1466BC8B228A /* WeeklyBarChartView.swift in Sources */ = {isa = PBXBuildFile; fileRef = C6A21CD5C2B8905CF28D262D /* WeeklyBarChartView.swift */; }; + C234367C2067A4180E150C1F /* SleepypodWatch.app in Embed Watch Content */ = {isa = PBXBuildFile; fileRef = C8D0A8FB2F4BBBA27661F94F /* SleepypodWatch.app */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; }; C410FCBED61138E4165AAB43 /* FirstBootTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = E152E058D4B193B3FD0F1D0D /* FirstBootTests.swift */; }; C4D7F73C779F1ED2B685A770 /* DataPipelineView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9C23ED1E696702811954A0DA /* DataPipelineView.swift */; }; C92D44C303EF1CBE9D6B8F3A /* SmartCurveView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 883B7737894F367125F721FF /* SmartCurveView.swift */; }; + CA5C2DE49D249EC0E268DD06 /* WatchMotionTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 18955C84857254C0B05ACDF6 /* WatchMotionTests.swift */; }; CA8A50DDD41F09B8AEA62585 /* ScheduleEndActionTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA2F416C6569A20CE25E7736 /* ScheduleEndActionTests.swift */; }; CA9D9A4FA299AD4C47BD59E4 /* MarketingTour.swift in Sources */ = {isa = PBXBuildFile; fileRef = 11D7A6E0B1458A3D80DFDC3C /* MarketingTour.swift */; }; CC96C885B1F06EF7B0288588 /* StartupTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CCC78B5D75C00C87626CA24 /* StartupTests.swift */; }; CD014B9CEE2C262CDD2637B5 /* SleepAnalyzerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = DE5C25057170EC9CC2049440 /* SleepAnalyzerTests.swift */; }; D45FD73FE3DE25E49C9FAD2D /* PrimingIndicator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 03E5DFA72720A6968DE3369C /* PrimingIndicator.swift */; }; + D4E15DA7B269D87F0CCB9DBF /* WatchMotionReceiver.swift in Sources */ = {isa = PBXBuildFile; fileRef = C5F8E3677668F3709EFC4ADD /* WatchMotionReceiver.swift */; }; D5D9989913D7AC299A9531E4 /* SleepProfile.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7A89CDBBC9BF2AAE10BA0302 /* SleepProfile.swift */; }; D6E26F612A74B9A7BE28C232 /* IBMPlexMono-Light.ttf in Resources */ = {isa = PBXBuildFile; fileRef = AC39EA7DCB2E1133715215F0 /* IBMPlexMono-Light.ttf */; }; D81EAE0FD3DA1C07FC06B43F /* DeviceSettingsCardView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 677BD56179BBE15ACC5E97C1 /* DeviceSettingsCardView.swift */; }; @@ -170,8 +179,29 @@ remoteGlobalIDString = DA66F8DC293B6B7F9BDC21B5; remoteInfo = Sleepypod; }; + E9F3817EBF7BDA21E6262065 /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = 4D367879AC27A1F41611261E /* Project object */; + proxyType = 1; + remoteGlobalIDString = FE325996E9C6510FD9FA4A2A; + remoteInfo = SleepypodWatch; + }; /* End PBXContainerItemProxy section */ +/* Begin PBXCopyFilesBuildPhase section */ + B340ABE72DD7257496CEAA50 /* Embed Watch Content */ = { + isa = PBXCopyFilesBuildPhase; + buildActionMask = 2147483647; + dstPath = "$(CONTENTS_FOLDER_PATH)/Watch"; + dstSubfolderSpec = 16; + files = ( + C234367C2067A4180E150C1F /* SleepypodWatch.app in Embed Watch Content */, + ); + name = "Embed Watch Content"; + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXCopyFilesBuildPhase section */ + /* Begin PBXFileReference section */ 00032A1688C27E11B0BF64CC /* ScheduleScreen.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ScheduleScreen.swift; sourceTree = ""; }; 0144EC2C3B96839B69D7A14A /* SleepypodProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SleepypodProtocol.swift; sourceTree = ""; }; @@ -189,8 +219,10 @@ 11D7A6E0B1458A3D80DFDC3C /* MarketingTour.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MarketingTour.swift; sourceTree = ""; }; 1458956B4751A13CE39D3C2C /* WaterLevelSheet.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WaterLevelSheet.swift; sourceTree = ""; }; 1680E783C6BA23F08E39895C /* StageChallengerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StageChallengerTests.swift; sourceTree = ""; }; + 18955C84857254C0B05ACDF6 /* WatchMotionTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WatchMotionTests.swift; sourceTree = ""; }; 1BA5BFA567D7FBA54153B602 /* MockAPIClient.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MockAPIClient.swift; sourceTree = ""; }; 1BBB2DEABE79587F398DEB17 /* APIEndpoint.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = APIEndpoint.swift; sourceTree = ""; }; + 25144B52B22AB54483983387 /* MotionRecorder.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MotionRecorder.swift; sourceTree = ""; }; 287A88B1C75E35CE5B9C219C /* StageTrainer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StageTrainer.swift; sourceTree = ""; }; 2941CA088FC1C77B69F36D60 /* DeviceManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DeviceManager.swift; sourceTree = ""; }; 295C867DF528905A55668F52 /* APIBackend.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = APIBackend.swift; sourceTree = ""; }; @@ -241,6 +273,7 @@ 7609963B2396D197BA589466 /* SleepAnalyzer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SleepAnalyzer.swift; sourceTree = ""; }; 76EDADBFA471AB6D43021528 /* WeekNavigatorView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WeekNavigatorView.swift; sourceTree = ""; }; 77A6653DA02CC01836DB61EA /* BedSensorScreen.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BedSensorScreen.swift; sourceTree = ""; }; + 7A6FF64E72F4ED36D4A05386 /* RecordingController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RecordingController.swift; sourceTree = ""; }; 7A89CDBBC9BF2AAE10BA0302 /* SleepProfile.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SleepProfile.swift; sourceTree = ""; }; 7B4A78F7220A8976E8047BF2 /* TempControlsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TempControlsView.swift; sourceTree = ""; }; 7BC90983ABD4505D2BE197D8 /* PiezoWaveformView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PiezoWaveformView.swift; sourceTree = ""; }; @@ -257,11 +290,13 @@ 970CD9734D4BC3ACBE225328 /* FreeSleepClient.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FreeSleepClient.swift; sourceTree = ""; }; 97C4FF2FF166CD9D12DEEBD0 /* WatchComparisonTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WatchComparisonTests.swift; sourceTree = ""; }; 987F4C796A69FBEA5B834DEF /* SetPointEditor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SetPointEditor.swift; sourceTree = ""; }; + 9AA7286021C48AD5A6B7E3D9 /* WatchMotionView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WatchMotionView.swift; sourceTree = ""; }; 9B1F67C21593EE6EACF74EB4 /* UpdateChecker.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UpdateChecker.swift; sourceTree = ""; }; 9C23ED1E696702811954A0DA /* DataPipelineView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DataPipelineView.swift; sourceTree = ""; }; 9CEE8733293904F78BD28CD9 /* StageFeatureParityTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StageFeatureParityTests.swift; sourceTree = ""; }; 9E902CB9201C2AE527A6C49F /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; A0EF147CC5E285EB9FCF9DBB /* ScheduleManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ScheduleManager.swift; sourceTree = ""; }; + A182F7D23DCC1F6F92576932 /* SleepypodWatchApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SleepypodWatchApp.swift; sourceTree = ""; }; A20D1974B2E3BAF1AB09CDAB /* RunOnceActiveBanner.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RunOnceActiveBanner.swift; sourceTree = ""; }; A22017869B6697F13244EF76 /* ShareSheet.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ShareSheet.swift; sourceTree = ""; }; A42ACA637081CABD4502D011 /* APIError.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = APIError.swift; sourceTree = ""; }; @@ -291,8 +326,10 @@ C1C013D953D18958F2C4B435 /* AdaptiveSimulator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AdaptiveSimulator.swift; sourceTree = ""; }; C436BD3DAD2E930203CCB620 /* StageFeatures.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StageFeatures.swift; sourceTree = ""; }; C5A1999947F6DB7202489B6C /* Haptics.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Haptics.swift; sourceTree = ""; }; + C5F8E3677668F3709EFC4ADD /* WatchMotionReceiver.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WatchMotionReceiver.swift; sourceTree = ""; }; C6A21CD5C2B8905CF28D262D /* WeeklyBarChartView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WeeklyBarChartView.swift; sourceTree = ""; }; C803665AE72E33B8B50730E9 /* PodDiscovery.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PodDiscovery.swift; sourceTree = ""; }; + C8D0A8FB2F4BBBA27661F94F /* SleepypodWatch.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = SleepypodWatch.app; sourceTree = BUILT_PRODUCTS_DIR; }; CB85BC15DC6D3030C728B6FC /* Side.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Side.swift; sourceTree = ""; }; CCB9B2C20054DF2DEFE406E5 /* TempBackdrop.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TempBackdrop.swift; sourceTree = ""; }; CCFDA266C4BF7ADCBDE06689 /* TapGestureConfigView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TapGestureConfigView.swift; sourceTree = ""; }; @@ -320,6 +357,7 @@ EF96D1D98331EFFC47B48C65 /* StageModelStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StageModelStore.swift; sourceTree = ""; }; F3313CC2AC5F797597D9D9D4 /* StageChallenger.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StageChallenger.swift; sourceTree = ""; }; F548261B6FA017552631862C /* ModernizationTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ModernizationTests.swift; sourceTree = ""; }; + F63D97596C1E062CB7C4917A /* WatchMotionFile.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WatchMotionFile.swift; sourceTree = ""; }; F6ECF94ACBCB10401F9F8FF7 /* NightPhases.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NightPhases.swift; sourceTree = ""; }; F9EC58AFC994E2BF0036D0D8 /* ServerStatus.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ServerStatus.swift; sourceTree = ""; }; FB15777D66DAF4DC889F469C /* NightPhasesStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NightPhasesStore.swift; sourceTree = ""; }; @@ -391,19 +429,30 @@ 3B37E02D8C926F0693D831AD = { isa = PBXGroup; children = ( + 854495BCFE82EEE911176CB7 /* Shared */, F06D1ECEA61B274469CE703A /* Sleepypod */, CB1F0598BD16F0631EF16962 /* SleepypodTests */, F1E22FC9A1CAD07AD1718C18 /* SleepypodUITests */, + F0FBBB58B1E099DB9410481F /* SleepypodWatch */, 5AB86CD34B74128771C1B77A /* Products */, ); sourceTree = ""; }; + 3F5BC6DFFE5EA07A0C000B24 /* WatchMotion */ = { + isa = PBXGroup; + children = ( + F63D97596C1E062CB7C4917A /* WatchMotionFile.swift */, + ); + path = WatchMotion; + sourceTree = ""; + }; 5AB86CD34B74128771C1B77A /* Products */ = { isa = PBXGroup; children = ( 3E42AF9CF86F91B8ED2C55BD /* Sleepypod.app */, B31B3D5B917F4A4D5DD9E99D /* SleepypodTests.xctest */, A4B9728437117BA2D2E88EAC /* SleepypodUITests.xctest */, + C8D0A8FB2F4BBBA27661F94F /* SleepypodWatch.app */, ); name = Products; sourceTree = ""; @@ -437,6 +486,14 @@ path = Sensor; sourceTree = ""; }; + 854495BCFE82EEE911176CB7 /* Shared */ = { + isa = PBXGroup; + children = ( + 3F5BC6DFFE5EA07A0C000B24 /* WatchMotion */, + ); + path = Shared; + sourceTree = ""; + }; 8697000FCAFDD383B67A51B1 /* Fonts */ = { isa = PBXGroup; children = ( @@ -511,6 +568,7 @@ 5CCC78B5D75C00C87626CA24 /* StartupTests.swift */, 3F6C0F4048FF9E24D4B5CD54 /* ThermalBedTests.swift */, 97C4FF2FF166CD9D12DEEBD0 /* WatchComparisonTests.swift */, + 18955C84857254C0B05ACDF6 /* WatchMotionTests.swift */, ED1DFFE1FE17140FDECFA256 /* Fixtures */, ); path = SleepypodTests; @@ -569,6 +627,7 @@ 4F4EAF0885B4206D02501DC8 /* StatusManager.swift */, 9B1F67C21593EE6EACF74EB4 /* UpdateChecker.swift */, D23E9157B3CA17789A8FEBE5 /* WatchComparison.swift */, + C5F8E3677668F3709EFC4ADD /* WatchMotionReceiver.swift */, ); path = Services; sourceTree = ""; @@ -582,6 +641,7 @@ CFB77E8BC025FA94E17FEF34 /* SettingsSheet.swift */, CCFDA266C4BF7ADCBDE06689 /* TapGestureConfigView.swift */, D7B2551BCDFC624E3EA1BA7A /* UpdateCardView.swift */, + 9AA7286021C48AD5A6B7E3D9 /* WatchMotionView.swift */, ); path = Settings; sourceTree = ""; @@ -626,6 +686,16 @@ path = Sleepypod; sourceTree = ""; }; + F0FBBB58B1E099DB9410481F /* SleepypodWatch */ = { + isa = PBXGroup; + children = ( + 25144B52B22AB54483983387 /* MotionRecorder.swift */, + 7A6FF64E72F4ED36D4A05386 /* RecordingController.swift */, + A182F7D23DCC1F6F92576932 /* SleepypodWatchApp.swift */, + ); + path = SleepypodWatch; + sourceTree = ""; + }; F1E22FC9A1CAD07AD1718C18 /* SleepypodUITests */ = { isa = PBXGroup; children = ( @@ -682,10 +752,12 @@ buildPhases = ( 9114BFFE24BA7F166D5D7F99 /* Sources */, 4948B4B486767F9346B69D79 /* Resources */, + B340ABE72DD7257496CEAA50 /* Embed Watch Content */, ); buildRules = ( ); dependencies = ( + B92A1E2DCA86BC201137A425 /* PBXTargetDependency */, ); name = Sleepypod; packageProductDependencies = ( @@ -694,6 +766,23 @@ productReference = 3E42AF9CF86F91B8ED2C55BD /* Sleepypod.app */; productType = "com.apple.product-type.application"; }; + FE325996E9C6510FD9FA4A2A /* SleepypodWatch */ = { + isa = PBXNativeTarget; + buildConfigurationList = 56D73CAFBE09467E0A30EDAE /* Build configuration list for PBXNativeTarget "SleepypodWatch" */; + buildPhases = ( + 5ABAB828E8CF1A4244519C40 /* Sources */, + ); + buildRules = ( + ); + dependencies = ( + ); + name = SleepypodWatch; + packageProductDependencies = ( + ); + productName = SleepypodWatch; + productReference = C8D0A8FB2F4BBBA27661F94F /* SleepypodWatch.app */; + productType = "com.apple.product-type.application"; + }; /* End PBXNativeTarget section */ /* Begin PBXProject section */ @@ -710,6 +799,10 @@ DevelopmentTeam = ZUR2343J7S; ProvisioningStyle = Automatic; }; + FE325996E9C6510FD9FA4A2A = { + DevelopmentTeam = ZUR2343J7S; + ProvisioningStyle = Automatic; + }; }; }; buildConfigurationList = FD0426B27C7B16C5827EF761 /* Build configuration list for PBXProject "Sleepypod" */; @@ -729,6 +822,7 @@ DA66F8DC293B6B7F9BDC21B5 /* Sleepypod */, D8808AABD4784645491292FA /* SleepypodTests */, BF06DBF6CAB62C58B7AECD55 /* SleepypodUITests */, + FE325996E9C6510FD9FA4A2A /* SleepypodWatch */, ); }; /* End PBXProject section */ @@ -785,6 +879,7 @@ CC96C885B1F06EF7B0288588 /* StartupTests.swift in Sources */, 78A0334C5FE90674DFAF5A57 /* ThermalBedTests.swift in Sources */, B69F31ED0A8F9F53BCA75DBB /* WatchComparisonTests.swift in Sources */, + CA5C2DE49D249EC0E268DD06 /* WatchMotionTests.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -798,6 +893,17 @@ ); runOnlyForDeploymentPostprocessing = 0; }; + 5ABAB828E8CF1A4244519C40 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 148DFAE07760ED2F88A712F5 /* MotionRecorder.swift in Sources */, + 4AF19000D28A42DA513268E0 /* RecordingController.swift in Sources */, + 5F52E018DFF7AA79E98AB53E /* SleepypodWatchApp.swift in Sources */, + 539F5AFD46F4BBFC1BB1EDC3 /* WatchMotionFile.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; 9114BFFE24BA7F166D5D7F99 /* Sources */ = { isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; @@ -912,6 +1018,9 @@ E6661819B24B1F3364F913AF /* VitalsReport.swift in Sources */, FC646DE2F3CD273C015FA1E4 /* WatchComparison.swift in Sources */, DD93EC2F6B715DA64823B351 /* WatchComparisonView.swift in Sources */, + 4F5BEB28BF7E5F3C71CF3E31 /* WatchMotionFile.swift in Sources */, + D4E15DA7B269D87F0CCB9DBF /* WatchMotionReceiver.swift in Sources */, + 1D47C1C23CC1F89056477831 /* WatchMotionView.swift in Sources */, 183555484763CDB6368F4063 /* WaterLevelSheet.swift in Sources */, 2D211849929D856169991F7B /* WeekNavigatorView.swift in Sources */, BFB6082091FA1466BC8B228A /* WeeklyBarChartView.swift in Sources */, @@ -931,6 +1040,11 @@ target = DA66F8DC293B6B7F9BDC21B5 /* Sleepypod */; targetProxy = 523D6D71DD140D3B5CED0C82 /* PBXContainerItemProxy */; }; + B92A1E2DCA86BC201137A425 /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = FE325996E9C6510FD9FA4A2A /* SleepypodWatch */; + targetProxy = E9F3817EBF7BDA21E6262065 /* PBXContainerItemProxy */; + }; /* End PBXTargetDependency section */ /* Begin XCBuildConfiguration section */ @@ -1014,11 +1128,29 @@ MTL_ENABLE_DEBUG_INFO = NO; MTL_FAST_MATH = YES; PRODUCT_NAME = "$(TARGET_NAME)"; - SDKROOT = iphoneos; SWIFT_COMPILATION_MODE = wholemodule; SWIFT_OPTIMIZATION_LEVEL = "-O"; SWIFT_STRICT_CONCURRENCY = complete; SWIFT_VERSION = 6.0; + WATCHOS_DEPLOYMENT_TARGET = 26.0; + }; + name = Release; + }; + 38AFD56A464A5C6A1963E06F /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CODE_SIGN_STYLE = Automatic; + DEVELOPMENT_TEAM = ZUR2343J7S; + INFOPLIST_KEY_CFBundleDisplayName = sleepypod; + INFOPLIST_KEY_NSMotionUsageDescription = "sleepypod records wrist movement overnight on this Watch and sends it only to your iPhone, as movement labels for training."; + INFOPLIST_KEY_WKCompanionAppBundleIdentifier = com.jonathanng.com.ios.sleepypod; + INFOPLIST_KEY_WKRunsIndependentlyOfCompanionApp = NO; + PRODUCT_BUNDLE_IDENTIFIER = com.jonathanng.com.ios.sleepypod.watchkitapp; + PRODUCT_NAME = SleepypodWatch; + SDKROOT = watchos; + SKIP_INSTALL = YES; + TARGETED_DEVICE_FAMILY = 4; }; name = Release; }; @@ -1062,6 +1194,24 @@ }; name = Release; }; + 9670DA9A4FDEBDF4DB82379B /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CODE_SIGN_STYLE = Automatic; + DEVELOPMENT_TEAM = ZUR2343J7S; + INFOPLIST_KEY_CFBundleDisplayName = sleepypod; + INFOPLIST_KEY_NSMotionUsageDescription = "sleepypod records wrist movement overnight on this Watch and sends it only to your iPhone, as movement labels for training."; + INFOPLIST_KEY_WKCompanionAppBundleIdentifier = com.jonathanng.com.ios.sleepypod; + INFOPLIST_KEY_WKRunsIndependentlyOfCompanionApp = NO; + PRODUCT_BUNDLE_IDENTIFIER = com.jonathanng.com.ios.sleepypod.watchkitapp; + PRODUCT_NAME = SleepypodWatch; + SDKROOT = watchos; + SKIP_INSTALL = YES; + TARGETED_DEVICE_FAMILY = 4; + }; + name = Debug; + }; A48A67AB9766ABD38F92DC04 /* Debug */ = { isa = XCBuildConfiguration; buildSettings = { @@ -1125,11 +1275,11 @@ MTL_FAST_MATH = YES; ONLY_ACTIVE_ARCH = YES; PRODUCT_NAME = "$(TARGET_NAME)"; - SDKROOT = iphoneos; SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG; SWIFT_OPTIMIZATION_LEVEL = "-Onone"; SWIFT_STRICT_CONCURRENCY = complete; SWIFT_VERSION = 6.0; + WATCHOS_DEPLOYMENT_TARGET = 26.0; }; name = Debug; }; @@ -1193,6 +1343,15 @@ defaultConfigurationIsVisible = 0; defaultConfigurationName = Debug; }; + 56D73CAFBE09467E0A30EDAE /* Build configuration list for PBXNativeTarget "SleepypodWatch" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 9670DA9A4FDEBDF4DB82379B /* Debug */, + 38AFD56A464A5C6A1963E06F /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Debug; + }; A2A47AAF19E41D49124C342B /* Build configuration list for PBXNativeTarget "Sleepypod" */ = { isa = XCConfigurationList; buildConfigurations = ( diff --git a/Sleepypod/Services/WatchMotionReceiver.swift b/Sleepypod/Services/WatchMotionReceiver.swift new file mode 100644 index 00000000..4bddcb7c --- /dev/null +++ b/Sleepypod/Services/WatchMotionReceiver.swift @@ -0,0 +1,88 @@ +import Foundation +import Observation +import WatchConnectivity + +/// Movement recordings received from the Watch, kept on this iPhone under Application Support/WatchMotion. +/// Nothing is uploaded; the user exports a file to the training workspace by hand. +struct WatchMotionInbox: Sendable { + let directory: URL + + static var standard: WatchMotionInbox { + let base = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask)[0] + return WatchMotionInbox(directory: base.appendingPathComponent("WatchMotion", isDirectory: true)) + } + + struct Recording: Identifiable, Sendable, Equatable { + var url: URL + var from: Date + var to: Date + var bins: Int + var id: URL { url } + } + + /// Validates an incoming file and stores it under its canonical name; a repeat delivery replaces the copy. + @discardableResult + func store(contentsOf source: URL) throws -> Recording { + let data = try Data(contentsOf: source) + let file = try WatchMotionFile.decode(data) + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + let destination = directory.appendingPathComponent(file.fileName) + try data.write(to: destination, options: .atomic) + return Self.recording(file, at: destination) + } + + /// Valid recordings, newest first. Unreadable files are skipped rather than shown. + func recordings() -> [Recording] { + let urls = (try? FileManager.default.contentsOfDirectory(at: directory, includingPropertiesForKeys: nil)) ?? [] + return urls.filter { $0.pathExtension == "json" } + .compactMap { url in + guard let data = try? Data(contentsOf: url), let file = try? WatchMotionFile.decode(data) else { return nil } + return Self.recording(file, at: url) + } + .sorted { $0.from > $1.from } + } + + private static func recording(_ file: WatchMotionFile, at url: URL) -> Recording { + Recording(url: url, from: Date(timeIntervalSince1970: Double(file.recordedFromMs) / 1000), + to: Date(timeIntervalSince1970: Double(file.recordedToMs) / 1000), bins: file.bins.count) + } +} + +@MainActor +@Observable +final class WatchMotionReceiver: NSObject { + private(set) var recordings: [WatchMotionInbox.Recording] = [] + private(set) var lastError: String? + nonisolated let inbox: WatchMotionInbox + + init(inbox: WatchMotionInbox = .standard) { + self.inbox = inbox + super.init() + recordings = inbox.recordings() + } + + func activate() { + guard WCSession.isSupported() else { return } + WCSession.default.delegate = self + WCSession.default.activate() + } + + func received(_ result: Result) { + switch result { + case .success: recordings = inbox.recordings(); lastError = nil + case .failure(let error): lastError = "A Watch recording could not be read: \(error.localizedDescription)" + } + } +} + +extension WatchMotionReceiver: WCSessionDelegate { + nonisolated func session(_ session: WCSession, activationDidCompleteWith activationState: WCSessionActivationState, error: Error?) {} + nonisolated func sessionDidBecomeInactive(_ session: WCSession) {} + nonisolated func sessionDidDeactivate(_ session: WCSession) { session.activate() } + + /// The transferred file is deleted when this returns, so it is copied before hopping to the main actor. + nonisolated func session(_ session: WCSession, didReceive file: WCSessionFile) { + let result = Result { try inbox.store(contentsOf: file.fileURL) } + Task { @MainActor in self.received(result) } + } +} diff --git a/Sleepypod/SleepypodApp.swift b/Sleepypod/SleepypodApp.swift index 792202ea..38518658 100644 --- a/Sleepypod/SleepypodApp.swift +++ b/Sleepypod/SleepypodApp.swift @@ -14,6 +14,7 @@ struct SleepypodApp: App { @State private var notificationRelay = NotificationRelay() @State private var healthSync: HealthSyncService @State private var stageModels: StageModelStore + @State private var watchMotion = WatchMotionReceiver() init() { let client = APIBackend.current.createClient() @@ -52,11 +53,13 @@ struct SleepypodApp: App { .environment(notificationRelay) .environment(healthSync) .environment(stageModels) + .environment(watchMotion) .preferredColorScheme(userProfile.appearance.colorScheme) .foregroundStyle(Theme.text1) .tint(Theme.text1) .task { sensorStream.notificationRelay = notificationRelay + watchMotion.activate() } .task(id: deviceManager.isConnected) { // Keep first-install setup focused on local network access. diff --git a/Sleepypod/Views/Settings/SettingsSheet.swift b/Sleepypod/Views/Settings/SettingsSheet.swift index 4570c519..b76399a2 100644 --- a/Sleepypod/Views/Settings/SettingsSheet.swift +++ b/Sleepypod/Views/Settings/SettingsSheet.swift @@ -56,6 +56,9 @@ struct SettingsSheet: View { RowValue(health.enabled ? "On" : "Off") } } + NavigationLink { WatchMotionView() } label: { + SettingsRow("Watch movement", icon: "applewatch", iconColor: Theme.text1, chevron: true) + } TempControlMenu(selection: $profile.tempControl, iconColor: Theme.text1, tile: false) if APIBackend.current.isDemo { Button { diff --git a/Sleepypod/Views/Settings/WatchMotionView.swift b/Sleepypod/Views/Settings/WatchMotionView.swift new file mode 100644 index 00000000..7bedb038 --- /dev/null +++ b/Sleepypod/Views/Settings/WatchMotionView.swift @@ -0,0 +1,37 @@ +import SwiftUI + +/// Overnight movement recordings sent from the sleepypod Watch app. Each can be shared to a computer for the +/// training workspace; nothing is uploaded automatically. +struct WatchMotionView: View { + @Environment(WatchMotionReceiver.self) private var receiver + + var body: some View { + ScrollView { + VStack(alignment: .leading, spacing: 16) { + Text("Start a recording on the Watch at bedtime. In the morning, send it from the Watch and it appears here. Share a file to a computer to use it as movement labels in the training workspace.") + .font(.footnote).foregroundStyle(Theme.text2).lineSpacing(3) + .padding(.horizontal, 8) + if let error = receiver.lastError { + Text(error).font(.footnote).foregroundStyle(Theme.red).padding(.horizontal, 8) + } + GroupedSection("RECORDINGS") { + GroupedCard { + if receiver.recordings.isEmpty { + SettingsRow("No recordings yet", subtitle: "Recordings from the Watch appear here") + } + ForEach(receiver.recordings) { recording in + ShareLink(item: recording.url) { + SettingsRow(recording.from.formatted(date: .abbreviated, time: .shortened), + subtitle: "\(Duration.seconds(recording.to.timeIntervalSince(recording.from)).formatted(.units(allowed: [.hours, .minutes], width: .abbreviated))) · \(recording.bins) bins", + icon: "square.and.arrow.up", iconColor: Theme.text1) + } + .buttonStyle(.plain) + } + } + } + } + .padding(16) + } + .navigationTitle("Watch movement") + } +} diff --git a/SleepypodTests/WatchMotionTests.swift b/SleepypodTests/WatchMotionTests.swift new file mode 100644 index 00000000..f2c23077 --- /dev/null +++ b/SleepypodTests/WatchMotionTests.swift @@ -0,0 +1,133 @@ +import Testing +import Foundation +@testable import Sleepypod + +/// The Watch's overnight movement summary: bins, jerk, the file format sleepypod/training reads, and the +/// iPhone inbox that stores what the Watch sends. +@Suite("Watch movement") +struct WatchMotionTests { + private func still(_ t: Double) -> MotionSample { MotionSample(time: t, x: 0, y: 0, z: 1) } + + @Test func binsAlignToUnixTimeAndOmitEmptySpans() { + let samples = [still(100.1), still(100.5), still(101.9), still(106.2)] + let bins = MotionSummarizer.summarize(samples, binSeconds: 2) + #expect(bins.startMs == [100_000, 106_000]) + #expect(bins.samples == [3, 1]) + #expect(bins.meanMagnitude == [1, 1]) + #expect(bins.magnitudeVariance == [0, 0]) + #expect(bins.maxJerk == [0, 0]) + #expect(bins.movingSamples == [0, 0]) + } + + @Test func jerkCountsFastChangesOnlyBetweenCloseReadings() { + let samples = [ + still(10.00), + MotionSample(time: 10.02, x: 0, y: 0, z: 1.1), // +0.1 g in 20 ms: 5 g/s + MotionSample(time: 10.04, x: 0, y: 0, z: 1.101), // 0.05 g/s + MotionSample(time: 11.00, x: 0, y: 0, z: 2.0), // 0.96 s gap: not a jerk + ] + let bins = MotionSummarizer.summarize(samples.reversed(), binSeconds: 2, jerkThreshold: 1, maxGap: 0.5) + #expect(bins.count == 1) + #expect(abs(bins.maxJerk[0] - 5) < 1e-6) + #expect(bins.movingSamples == [1]) + let mean = (1 + 1.1 + 1.101 + 2.0) / 4 + #expect(abs(bins.meanMagnitude[0] - mean) < 1e-9) + #expect(bins.magnitudeVariance[0] > 0) + } + + @Test func fileCarriesProvenanceAndOptionalRaw() throws { + let zone = try #require(TimeZone(identifier: "America/Los_Angeles")) + let from = Date(timeIntervalSince1970: 1_800_000_000), to = from.addingTimeInterval(4) + let samples = [still(1_800_000_000.5), MotionSample(time: 1_800_000_002.25, x: 0.6, y: 0, z: 0.8)] + let file = MotionSummarizer.file(samples: samples, from: from, to: to, timeZone: zone, device: "Watch") + #expect(file.version == "watch-motion-v1") + #expect(file.recordedFromMs == 1_800_000_000_000) + #expect(file.recordedToMs == 1_800_000_004_000) + #expect(file.timeZone == "America/Los_Angeles") + #expect(file.utcOffsetSeconds == zone.secondsFromGMT(for: from)) + #expect(file.bins.count == 2) + #expect(file.raw == nil) + #expect(file.fileName == "watch-motion-1800000000000.json") + let raw = MotionSummarizer.file(samples: samples, from: from, to: to, timeZone: zone, device: "Watch", includeRaw: true) + #expect(raw.raw?.first == [1_800_000_000_500, 0, 0, 1]) + } + + @Test func encodingRoundTripsAndRejectsBadFiles() throws { + let file = MotionSummarizer.file(samples: [still(5), still(7)], from: Date(timeIntervalSince1970: 4), + to: Date(timeIntervalSince1970: 8), timeZone: .gmt, device: "Watch") + #expect(try WatchMotionFile.decode(file.encoded()) == file) + + var wrongVersion = file + wrongVersion.version = "watch-motion-v0" + #expect(throws: WatchMotionFile.FormatError.unsupportedVersion("watch-motion-v0")) { try WatchMotionFile.decode(wrongVersion.encoded()) } + var ragged = file + ragged.bins.maxJerk.removeLast() + #expect(throws: WatchMotionFile.FormatError.mismatchedColumns) { try WatchMotionFile.decode(ragged.encoded()) } + var backwards = file + backwards.recordedToMs = backwards.recordedFromMs - 1 + #expect(throws: WatchMotionFile.FormatError.badInterval) { try WatchMotionFile.decode(backwards.encoded()) } + #expect(throws: (any Error).self) { try WatchMotionFile.decode(Data("{}".utf8)) } + } + + @Test func recordingPlanClampsAndReadsBackOnlyWhatHasPassed() { + let start = Date(timeIntervalSince1970: 1000) + #expect(RecordingPlan(start: start, hours: 20).duration == 12 * 3600) + #expect(RecordingPlan(start: start, hours: 0).duration == 1800) + let plan = RecordingPlan(start: start, hours: 8) + #expect(plan.end == start.addingTimeInterval(8 * 3600)) + #expect(plan.readableInterval(now: start) == nil) + #expect(plan.readableInterval(now: start.addingTimeInterval(60))?.duration == 60) + #expect(plan.readableInterval(now: start.addingTimeInterval(9 * 3600))?.end == plan.end) + } + + @Test func inboxStoresValidFilesNewestFirstAndSkipsBadOnes() throws { + let root = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + defer { try? FileManager.default.removeItem(at: root) } + let inbox = WatchMotionInbox(directory: root.appendingPathComponent("WatchMotion")) + #expect(inbox.recordings().isEmpty) + + func incoming(_ startSeconds: Double) throws -> URL { + let file = MotionSummarizer.file(samples: [still(startSeconds + 1)], from: Date(timeIntervalSince1970: startSeconds), + to: Date(timeIntervalSince1970: startSeconds + 3600), timeZone: .gmt, device: "Watch") + let url = root.appendingPathComponent(UUID().uuidString + ".json") + try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true) + try file.encoded().write(to: url) + return url + } + let older = try inbox.store(contentsOf: incoming(1000)) + _ = try inbox.store(contentsOf: incoming(9000)) + _ = try inbox.store(contentsOf: incoming(1000)) // a repeat delivery replaces the copy + #expect(older.bins == 1) + #expect(older.url.lastPathComponent == "watch-motion-1000000.json") + try Data("not json".utf8).write(to: inbox.directory.appendingPathComponent("broken.json")) + try Data("x".utf8).write(to: inbox.directory.appendingPathComponent("notes.txt")) + let listed = inbox.recordings() + #expect(listed.map(\.from.timeIntervalSince1970) == [9000, 1000]) + + let bad = root.appendingPathComponent("bad.json") + try Data("{}".utf8).write(to: bad) + #expect(throws: (any Error).self) { try inbox.store(contentsOf: bad) } + } + + @MainActor @Test func receiverListsTheInboxAtLaunch() throws { + let root = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + defer { try? FileManager.default.removeItem(at: root) } + let inbox = WatchMotionInbox(directory: root) + let file = MotionSummarizer.file(samples: [still(1)], from: Date(timeIntervalSince1970: 0), + to: Date(timeIntervalSince1970: 2), timeZone: .gmt, device: "Watch") + try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true) + try file.encoded().write(to: root.appendingPathComponent(file.fileName)) + let receiver = WatchMotionReceiver(inbox: inbox) + #expect(receiver.recordings.count == 1) + #expect(receiver.lastError == nil) + #expect(WatchMotionInbox.standard.directory.lastPathComponent == "WatchMotion") + + receiver.received(.failure(WatchMotionFile.FormatError.badInterval)) + #expect(receiver.lastError?.hasPrefix("A Watch recording could not be read") == true) + try FileManager.default.removeItem(at: root.appendingPathComponent(file.fileName)) + receiver.received(.success(WatchMotionInbox.Recording(url: root, from: .now, to: .now, bins: 0))) + #expect(receiver.lastError == nil) + #expect(receiver.recordings.isEmpty) + receiver.activate() + } +} diff --git a/SleepypodWatch/MotionRecorder.swift b/SleepypodWatch/MotionRecorder.swift new file mode 100644 index 00000000..1a3d0e06 --- /dev/null +++ b/SleepypodWatch/MotionRecorder.swift @@ -0,0 +1,48 @@ +import CoreMotion +import Foundation + +enum MotionAuthorization: Sendable { + case notDetermined, denied, restricted, authorized +} + +/// The recorder behind a protocol, so the controller can be exercised without hardware. +protocol AccelerometerSource: Sendable { + var isAvailable: Bool { get } + var authorization: MotionAuthorization { get } + /// Asks the system to record in the background; the app need not stay open. + func record(duration: TimeInterval) + /// Readings for an interval the system kept. Slow for a whole night: call off the main actor. + func samples(in interval: DateInterval) -> [MotionSample] +} + +struct CoreMotionAccelerometer: AccelerometerSource { + var isAvailable: Bool { CMSensorRecorder.isAccelerometerRecordingAvailable() } + + var authorization: MotionAuthorization { + switch CMSensorRecorder.authorizationStatus() { + case .authorized: .authorized + case .denied: .denied + case .restricted: .restricted + default: .notDetermined + } + } + + func record(duration: TimeInterval) { + CMSensorRecorder().recordAccelerometer(forDuration: duration) + } + + func samples(in interval: DateInterval) -> [MotionSample] { + guard let list = CMSensorRecorder().accelerometerData(from: interval.start, to: interval.end) else { return [] } + var out: [MotionSample] = [] + // CMRecordedAccelerometerData.timestamp is seconds since boot; startDate is the wall-clock time. + for case let reading as CMRecordedAccelerometerData in list { + let a = reading.acceleration + out.append(MotionSample(time: reading.startDate.timeIntervalSince1970, x: a.x, y: a.y, z: a.z)) + } + return out + } +} + +extension CMSensorDataList: @retroactive Sequence { + public func makeIterator() -> NSFastEnumerationIterator { NSFastEnumerationIterator(self) } +} diff --git a/SleepypodWatch/RecordingController.swift b/SleepypodWatch/RecordingController.swift new file mode 100644 index 00000000..db993820 --- /dev/null +++ b/SleepypodWatch/RecordingController.swift @@ -0,0 +1,80 @@ +import Foundation +import Observation +import WatchConnectivity +import WatchKit + +/// Starts tonight's recording and, in the morning, summarizes it and sends the file to the iPhone. +@MainActor +@Observable +final class RecordingController: NSObject { + private static let planKey = "watchMotion.plan" + + private let source: any AccelerometerSource + private let defaults: UserDefaults + private(set) var plan: RecordingPlan? + private(set) var status = "" + private(set) var busy = false + var hours = 9.0 + var includeRaw = false + + init(source: any AccelerometerSource = CoreMotionAccelerometer(), defaults: UserDefaults = .standard) { + self.source = source + self.defaults = defaults + super.init() + if let data = defaults.data(forKey: Self.planKey) { + plan = try? JSONDecoder().decode(RecordingPlan.self, from: data) + } + if WCSession.isSupported() { + WCSession.default.delegate = self + WCSession.default.activate() + } + } + + var availability: String { + guard source.isAvailable else { return "Recording isn't available on this Watch." } + switch source.authorization { + case .denied, .restricted: return "Allow Motion & Fitness for sleepypod in the Watch's Settings." + case .notDetermined, .authorized: return "" + } + } + + func recordTonight(now: Date = .now) { + let next = RecordingPlan(start: now, hours: hours) + source.record(duration: next.duration) + plan = next + if let data = try? JSONEncoder().encode(next) { defaults.set(data, forKey: Self.planKey) } + status = "Recording until \(next.end.formatted(date: .omitted, time: .shortened)). You can close the app." + } + + /// Reads what the system kept, summarizes it and queues it for the iPhone; WatchConnectivity retries delivery. + func send(now: Date = .now) async { + guard let plan, let interval = plan.readableInterval(now: now) else { + status = "Nothing recorded yet." + return + } + busy = true + defer { busy = false } + let source = source, includeRaw = includeRaw + let device = WKInterfaceDevice.current().model + " · watchOS " + WKInterfaceDevice.current().systemVersion + let file = await Task.detached(priority: .userInitiated) { + MotionSummarizer.file(samples: source.samples(in: interval), from: interval.start, to: interval.end, + timeZone: .current, device: device, includeRaw: includeRaw) + }.value + guard file.bins.count > 0 else { + status = "The Watch kept no readings for this night." + return + } + do { + let url = FileManager.default.temporaryDirectory.appendingPathComponent(file.fileName) + try file.encoded().write(to: url, options: .atomic) + WCSession.default.transferFile(url, metadata: ["kind": "watch-motion", "version": file.version]) + status = "Sent \(file.bins.count) bins to the iPhone." + } catch { + status = "Could not save the recording: \(error.localizedDescription)" + } + } +} + +extension RecordingController: WCSessionDelegate { + nonisolated func session(_ session: WCSession, activationDidCompleteWith activationState: WCSessionActivationState, error: Error?) {} +} diff --git a/SleepypodWatch/SleepypodWatchApp.swift b/SleepypodWatch/SleepypodWatchApp.swift new file mode 100644 index 00000000..1d3a1435 --- /dev/null +++ b/SleepypodWatch/SleepypodWatchApp.swift @@ -0,0 +1,43 @@ +import SwiftUI + +@main +struct SleepypodWatchApp: App { + @State private var controller = RecordingController() + + var body: some Scene { + WindowGroup { + RecorderView().environment(controller) + } + } +} + +struct RecorderView: View { + @Environment(RecordingController.self) private var controller + + var body: some View { + @Bindable var controller = controller + ScrollView { + VStack(alignment: .leading, spacing: 10) { + Text("sleepypod").font(.headline) + Text("Records wrist movement overnight as labels for the movement classifier. It stays on this Watch until you send it.") + .font(.footnote).foregroundStyle(.secondary) + if !controller.availability.isEmpty { + Text(controller.availability).font(.footnote).foregroundStyle(.orange) + } + Stepper(value: $controller.hours, in: 1...12, step: 0.5) { + Text("\(controller.hours.formatted(.number.precision(.fractionLength(1)))) h") + } + Button("Record tonight") { controller.recordTonight() } + .disabled(!controller.availability.isEmpty && controller.availability.hasPrefix("Recording isn't")) + Toggle("Include raw 50 Hz", isOn: $controller.includeRaw).font(.footnote) + Button(controller.busy ? "Sending…" : "Send to iPhone") { + Task { await controller.send() } + } + .disabled(controller.busy || controller.plan == nil) + if !controller.status.isEmpty { + Text(controller.status).font(.footnote) + } + } + } + } +} diff --git a/docs/watch-motion-format.md b/docs/watch-motion-format.md new file mode 100644 index 00000000..340758d7 --- /dev/null +++ b/docs/watch-motion-format.md @@ -0,0 +1,47 @@ +# Watch movement recordings + +The sleepypod Watch app records wrist acceleration overnight and sends a summary to the iPhone. The training workspace (sleepypod/training) uses it as automatic movement labels for the pod's movement classifier. Watch motion is a label for *movement*, never a sleep-stage feature: Watch sleep stages are partly derived from the same accelerometer. + +## How a recording happens + +1. At bedtime, open sleepypod on the Watch, choose a duration (1–12 h) and tap **Record tonight**. The app asks for Motion & Fitness the first time. It calls `CMSensorRecorder.recordAccelerometer(forDuration:)`; the system records at 50 Hz in the background, so the app can close. +2. In the morning, tap **Send to iPhone**. The Watch reads the recorded span with `accelerometerData(from:to:)`, summarizes it and queues the file with WatchConnectivity `transferFile`, which retries until the iPhone receives it. +3. On the iPhone, the file appears under **Settings → Watch movement**, stored in Application Support/WatchMotion. Share it to a computer from there. Nothing is uploaded automatically. + +## File format (`watch-motion-v1`) + +JSON, named `watch-motion-.json`, keys sorted. + +| Field | Type | Meaning | +| --- | --- | --- | +| `version` | string | `watch-motion-v1` | +| `recordedFromMs`, `recordedToMs` | integer | Requested span, Unix milliseconds (UTC) | +| `timeZone` | string | IANA zone of the Watch at the start, for example `America/Los_Angeles` | +| `utcOffsetSeconds` | integer | That zone's offset at `recordedFromMs` | +| `sampleRateHz` | number | Recorder rate, 50 | +| `binSeconds` | number | Bin width, 2 | +| `jerkThresholdGPerSecond` | number | Threshold for `movingSamples`, 1.0 | +| `device` | string | Watch model and watchOS version | +| `bins` | object | Columnar arrays, one entry per bin with readings | +| `raw` | array or absent | Optional `[unixMs, x, y, z]` readings in g; off by default | + +`bins` columns, all the same length: + +| Column | Unit | Meaning | +| --- | --- | --- | +| `startMs` | Unix ms | Bin start, aligned to Unix time (multiples of `binSeconds`) | +| `samples` | count | Readings in the bin (≈100 for a full 2 s bin) | +| `meanMagnitude` | g | Mean of \|a\| = √(x²+y²+z²), gravity included (≈1 at rest) | +| `magnitudeVariance` | g² | Population variance of \|a\| in the bin | +| `maxJerk` | g/s | Largest \|Δ\|a\|\| / Δt between consecutive readings ≤ 0.5 s apart | +| `movingSamples` | count | Readings whose jerk exceeds `jerkThresholdGPerSecond` | + +Bins with no readings are omitted: a missing bin means no data, never stillness. Times are the recorder's wall-clock `startDate` for each reading. + +## Limits + +- Not yet verified on a real Watch overnight: background recording, battery cost and delivery are untested. +- The system keeps recorded data for about three days; send it the next morning. +- One request records at most 12 hours. +- A night of 2 s bins is a few hundred kilobytes; raw 50 Hz readings are tens of megabytes, so raw export is off by default. +- The pod clock and the Watch clock are independent; the training workspace aligns them by recorded time and should check the offset against known events such as getting into bed. diff --git a/project.yml b/project.yml index 6e7b6fad..866dfd9e 100644 --- a/project.yml +++ b/project.yml @@ -3,6 +3,7 @@ options: bundleIdPrefix: com.jonathanng.com.ios.sleepypod deploymentTarget: iOS: "26.0" + watchOS: "26.0" xcodeVersion: "26" createIntermediateGroups: true defaultConfig: Debug @@ -26,6 +27,9 @@ targets: platform: iOS sources: - path: Sleepypod + - path: Shared + dependencies: + - target: SleepypodWatch entitlements: path: Sleepypod/Sleepypod.entitlements properties: @@ -44,6 +48,25 @@ targets: DEVELOPMENT_TEAM: ZUR2343J7S PRODUCT_NAME: Sleepypod + SleepypodWatch: + type: application + platform: watchOS + sources: + - path: SleepypodWatch + - path: Shared + settings: + base: + PRODUCT_BUNDLE_IDENTIFIER: com.jonathanng.com.ios.sleepypod.watchkitapp + PRODUCT_NAME: SleepypodWatch + INFOPLIST_KEY_CFBundleDisplayName: sleepypod + INFOPLIST_KEY_WKCompanionAppBundleIdentifier: com.jonathanng.com.ios.sleepypod + INFOPLIST_KEY_WKRunsIndependentlyOfCompanionApp: false + INFOPLIST_KEY_NSMotionUsageDescription: "sleepypod records wrist movement overnight on this Watch and sends it only to your iPhone, as movement labels for training." + TARGETED_DEVICE_FAMILY: 4 + CODE_SIGN_STYLE: Automatic + DEVELOPMENT_TEAM: ZUR2343J7S + SKIP_INSTALL: true + SleepypodTests: type: bundle.unit-test platform: iOS From 6242703a0e860f9a299ebd9cca88a30e1281bd99 Mon Sep 17 00:00:00 2001 From: Jonathan Ng Date: Thu, 22 Oct 2026 20:03:54 -0700 Subject: [PATCH 2/2] fix: mark the Watch app bundle and refuse to record without motion access Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_016Wac1uHSpomCEBtwBdw1YA Signed-off-by: Jonathan Ng --- Sleepypod.xcodeproj/project.pbxproj | 2 ++ SleepypodWatch/RecordingController.swift | 13 +++++++++++++ SleepypodWatch/SleepypodWatchApp.swift | 2 +- project.yml | 1 + 4 files changed, 17 insertions(+), 1 deletion(-) diff --git a/Sleepypod.xcodeproj/project.pbxproj b/Sleepypod.xcodeproj/project.pbxproj index 2f450845..519442e3 100644 --- a/Sleepypod.xcodeproj/project.pbxproj +++ b/Sleepypod.xcodeproj/project.pbxproj @@ -1144,6 +1144,7 @@ DEVELOPMENT_TEAM = ZUR2343J7S; INFOPLIST_KEY_CFBundleDisplayName = sleepypod; INFOPLIST_KEY_NSMotionUsageDescription = "sleepypod records wrist movement overnight on this Watch and sends it only to your iPhone, as movement labels for training."; + INFOPLIST_KEY_WKApplication = YES; INFOPLIST_KEY_WKCompanionAppBundleIdentifier = com.jonathanng.com.ios.sleepypod; INFOPLIST_KEY_WKRunsIndependentlyOfCompanionApp = NO; PRODUCT_BUNDLE_IDENTIFIER = com.jonathanng.com.ios.sleepypod.watchkitapp; @@ -1202,6 +1203,7 @@ DEVELOPMENT_TEAM = ZUR2343J7S; INFOPLIST_KEY_CFBundleDisplayName = sleepypod; INFOPLIST_KEY_NSMotionUsageDescription = "sleepypod records wrist movement overnight on this Watch and sends it only to your iPhone, as movement labels for training."; + INFOPLIST_KEY_WKApplication = YES; INFOPLIST_KEY_WKCompanionAppBundleIdentifier = com.jonathanng.com.ios.sleepypod; INFOPLIST_KEY_WKRunsIndependentlyOfCompanionApp = NO; PRODUCT_BUNDLE_IDENTIFIER = com.jonathanng.com.ios.sleepypod.watchkitapp; diff --git a/SleepypodWatch/RecordingController.swift b/SleepypodWatch/RecordingController.swift index db993820..5391f6a7 100644 --- a/SleepypodWatch/RecordingController.swift +++ b/SleepypodWatch/RecordingController.swift @@ -38,7 +38,20 @@ final class RecordingController: NSObject { } } + /// Recording needs the sensor and Motion & Fitness permission (or a prompt still to come). + var canRecord: Bool { + guard source.isAvailable else { return false } + switch source.authorization { + case .denied, .restricted: return false + case .notDetermined, .authorized: return true + } + } + func recordTonight(now: Date = .now) { + guard canRecord else { + status = availability + return + } let next = RecordingPlan(start: now, hours: hours) source.record(duration: next.duration) plan = next diff --git a/SleepypodWatch/SleepypodWatchApp.swift b/SleepypodWatch/SleepypodWatchApp.swift index 1d3a1435..223f8680 100644 --- a/SleepypodWatch/SleepypodWatchApp.swift +++ b/SleepypodWatch/SleepypodWatchApp.swift @@ -28,7 +28,7 @@ struct RecorderView: View { Text("\(controller.hours.formatted(.number.precision(.fractionLength(1)))) h") } Button("Record tonight") { controller.recordTonight() } - .disabled(!controller.availability.isEmpty && controller.availability.hasPrefix("Recording isn't")) + .disabled(!controller.canRecord) Toggle("Include raw 50 Hz", isOn: $controller.includeRaw).font(.footnote) Button(controller.busy ? "Sending…" : "Send to iPhone") { Task { await controller.send() } diff --git a/project.yml b/project.yml index 866dfd9e..b4b78080 100644 --- a/project.yml +++ b/project.yml @@ -60,6 +60,7 @@ targets: PRODUCT_NAME: SleepypodWatch INFOPLIST_KEY_CFBundleDisplayName: sleepypod INFOPLIST_KEY_WKCompanionAppBundleIdentifier: com.jonathanng.com.ios.sleepypod + INFOPLIST_KEY_WKApplication: true INFOPLIST_KEY_WKRunsIndependentlyOfCompanionApp: false INFOPLIST_KEY_NSMotionUsageDescription: "sleepypod records wrist movement overnight on this Watch and sends it only to your iPhone, as movement labels for training." TARGETED_DEVICE_FAMILY: 4