From 76da0ce7aa14c652d23974603fb00b97cfeb6b71 Mon Sep 17 00:00:00 2001 From: Taylor Date: Wed, 18 Feb 2026 09:03:12 -0800 Subject: [PATCH 1/4] Add AutoPresets: activity-based preset automation Automatically activates insulin override presets during walking/running via CoreMotion pedometer and activity classifier. Feature-flagged off by default (AutoPresets_FeatureFlags.isEnabled). 8 new files in AutoPresets/ subdirectories, 2 existing files modified (LoopDataManager delegate + SettingsView navigation link). --- Loop.xcodeproj/project.pbxproj | 82 ++- ...AutoPresets_ActivityDetectionManager.swift | 542 +++++++++++++++++ .../AutoPresets/AutoPresets_Coordinator.swift | 314 ++++++++++ .../AutoPresets/AutoPresets_Delegate.swift | 29 + .../AutoPresets/AutoPresets_Logger.swift | 163 ++++++ .../AutoPresets/AutoPresets_Storage.swift | 199 +++++++ Loop/Managers/LoopDataManager.swift | 45 +- .../AutoPresets/AutoPresets_Models.swift | 228 ++++++++ .../AutoPresets_FeatureFlags.swift | 34 ++ .../AutoPresets_SettingsView.swift | 551 ++++++++++++++++++ Loop/Views/SettingsView.swift | 12 + 11 files changed, 2192 insertions(+), 7 deletions(-) create mode 100644 Loop/Managers/AutoPresets/AutoPresets_ActivityDetectionManager.swift create mode 100644 Loop/Managers/AutoPresets/AutoPresets_Coordinator.swift create mode 100644 Loop/Managers/AutoPresets/AutoPresets_Delegate.swift create mode 100644 Loop/Managers/AutoPresets/AutoPresets_Logger.swift create mode 100644 Loop/Managers/AutoPresets/AutoPresets_Storage.swift create mode 100644 Loop/Models/AutoPresets/AutoPresets_Models.swift create mode 100644 Loop/Resources/AutoPresets/AutoPresets_FeatureFlags.swift create mode 100644 Loop/Views/AutoPresets/AutoPresets_SettingsView.swift diff --git a/Loop.xcodeproj/project.pbxproj b/Loop.xcodeproj/project.pbxproj index 4767ba3142..48362eebf9 100644 --- a/Loop.xcodeproj/project.pbxproj +++ b/Loop.xcodeproj/project.pbxproj @@ -7,6 +7,14 @@ objects = { /* Begin PBXBuildFile section */ + B9D78BD57F35DBCFEC65530D /* AutoPresets_Coordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 31B2D28F4F6C8955AB18A98C /* AutoPresets_Coordinator.swift */; }; + BACF15F57C2B75D78B797778 /* AutoPresets_Delegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = D17206C8109E75D64068F3D1 /* AutoPresets_Delegate.swift */; }; + B1F90AD96E591453CF003AC2 /* AutoPresets_Logger.swift in Sources */ = {isa = PBXBuildFile; fileRef = F92C032F72116EA4C06250F9 /* AutoPresets_Logger.swift */; }; + 211A65C85F6FEC59ED083F2D /* AutoPresets_Storage.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1EED74A3E39956E5218453D1 /* AutoPresets_Storage.swift */; }; + 0556C20E91536D540ED3D91E /* AutoPresets_ActivityDetectionManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 58CA1221236A70935E684238 /* AutoPresets_ActivityDetectionManager.swift */; }; + FFF05E4789A2D06526EB0A24 /* AutoPresets_SettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 177A32DF2F91D26776BF2C7F /* AutoPresets_SettingsView.swift */; }; + 232E357E321361C66498FEEA /* AutoPresets_Models.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D90E77F7FA3F7557C52EA5B /* AutoPresets_Models.swift */; }; + 13992ED79F79937110B6BFCD /* AutoPresets_FeatureFlags.swift in Sources */ = {isa = PBXBuildFile; fileRef = 593F5B3264F8383861CE3027 /* AutoPresets_FeatureFlags.swift */; }; 1419606428D9550400BA86E0 /* LoopKitUI.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 437AFEE6203688CF008C4892 /* LoopKitUI.framework */; }; 1419606928D9554E00BA86E0 /* LoopKitUI.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 437AFEE6203688CF008C4892 /* LoopKitUI.framework */; }; 1419606A28D955BC00BA86E0 /* MockKitUI.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = C101947127DD473C004E7EB8 /* MockKitUI.framework */; }; @@ -758,6 +766,14 @@ /* End PBXCopyFilesBuildPhase section */ /* Begin PBXFileReference section */ + 31B2D28F4F6C8955AB18A98C /* AutoPresets_Coordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AutoPresets_Coordinator.swift; sourceTree = ""; }; + D17206C8109E75D64068F3D1 /* AutoPresets_Delegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AutoPresets_Delegate.swift; sourceTree = ""; }; + F92C032F72116EA4C06250F9 /* AutoPresets_Logger.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AutoPresets_Logger.swift; sourceTree = ""; }; + 1EED74A3E39956E5218453D1 /* AutoPresets_Storage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AutoPresets_Storage.swift; sourceTree = ""; }; + 58CA1221236A70935E684238 /* AutoPresets_ActivityDetectionManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AutoPresets_ActivityDetectionManager.swift; sourceTree = ""; }; + 177A32DF2F91D26776BF2C7F /* AutoPresets_SettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AutoPresets_SettingsView.swift; sourceTree = ""; }; + 2D90E77F7FA3F7557C52EA5B /* AutoPresets_Models.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AutoPresets_Models.swift; sourceTree = ""; }; + 593F5B3264F8383861CE3027 /* AutoPresets_FeatureFlags.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AutoPresets_FeatureFlags.swift; sourceTree = ""; }; 142CB7582A60BF2E0075748A /* EditMode.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = EditMode.swift; sourceTree = ""; }; 142CB75A2A60BFC30075748A /* FavoriteFoodsView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = FavoriteFoodsView.swift; sourceTree = ""; }; 1452F4A82A851C9400F8B9E4 /* AddEditFavoriteFoodViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AddEditFavoriteFoodViewModel.swift; sourceTree = ""; }; @@ -1520,6 +1536,50 @@ /* End PBXFrameworksBuildPhase section */ /* Begin PBXGroup section */ + 7B0AE0D9D2D919C6882C0799 /* AutoPresets */ = { + isa = PBXGroup; + children = ( + 31B2D28F4F6C8955AB18A98C /* AutoPresets_Coordinator.swift */, + D17206C8109E75D64068F3D1 /* AutoPresets_Delegate.swift */, + F92C032F72116EA4C06250F9 /* AutoPresets_Logger.swift */, + 1EED74A3E39956E5218453D1 /* AutoPresets_Storage.swift */, + 58CA1221236A70935E684238 /* AutoPresets_ActivityDetectionManager.swift */, + ); + path = AutoPresets; + sourceTree = ""; + }; + E018293E3B1A901519B37E05 /* AutoPresets */ = { + isa = PBXGroup; + children = ( + 177A32DF2F91D26776BF2C7F /* AutoPresets_SettingsView.swift */, + ); + path = AutoPresets; + sourceTree = ""; + }; + 137AA12EFF968E58FEC07BF3 /* AutoPresets */ = { + isa = PBXGroup; + children = ( + 2D90E77F7FA3F7557C52EA5B /* AutoPresets_Models.swift */, + ); + path = AutoPresets; + sourceTree = ""; + }; + F37727DBE886D7AF624C93AE /* AutoPresets */ = { + isa = PBXGroup; + children = ( + 593F5B3264F8383861CE3027 /* AutoPresets_FeatureFlags.swift */, + ); + path = AutoPresets; + sourceTree = ""; + }; + 6D8BAA86B3F7DFB7735A618B /* Resources */ = { + isa = PBXGroup; + children = ( + F37727DBE886D7AF624C93AE /* AutoPresets */, + ); + path = Resources; + sourceTree = ""; + }; 14B1736128AED9EC006CCD7C /* Loop Widget Extension */ = { isa = PBXGroup; children = ( @@ -1656,7 +1716,8 @@ 43757D131C06F26C00910CB9 /* Models */ = { isa = PBXGroup; children = ( - DDC389F52A2B61750066E2E8 /* ApplicationFactorStrategy.swift */, + + 137AA12EFF968E58FEC07BF3 /* AutoPresets */, DDC389F52A2B61750066E2E8 /* ApplicationFactorStrategy.swift */, B4E2022F2661063E009421B5 /* AutomaticDosingStatus.swift */, A9FB75F0252BE320004C7D3F /* BolusDosingDecision.swift */, DDC389F92A2B62470066E2E8 /* ConstantApplicationFactorStrategy.swift */, @@ -1722,7 +1783,8 @@ 43776F8E1B8022E90074EA36 /* Loop */ = { isa = PBXGroup; children = ( - C16DA84022E8E104008624C2 /* Plugins */, + + 6D8BAA86B3F7DFB7735A618B /* Resources */, C16DA84022E8E104008624C2 /* Plugins */, B66D1F322E6A5D6600471149 /* Localizable.xcstrings */, B66D1F382E6A5D6600471149 /* InfoPlist.xcstrings */, 43EDEE6B1CF2E12A00393BE3 /* Loop.entitlements */, @@ -1960,7 +2022,8 @@ 43F5C2CF1B92A2ED003EB13D /* Views */ = { isa = PBXGroup; children = ( - 1452F4AA2A851EDF00F8B9E4 /* AddEditFavoriteFoodView.swift */, + + E018293E3B1A901519B37E05 /* AutoPresets */, 1452F4AA2A851EDF00F8B9E4 /* AddEditFavoriteFoodView.swift */, B4001CED28CBBC82002FB414 /* AlertManagementView.swift */, 897A5A9524C2175B00C4E71D /* BolusEntryView.swift */, C1F8B1D122375E4200DD66CF /* BolusProgressTableViewCell.swift */, @@ -2001,7 +2064,8 @@ 43F5C2E41B93C5D4003EB13D /* Managers */ = { isa = PBXGroup; children = ( - B42D124228D371C400E43D22 /* AlertMuter.swift */, + + 7B0AE0D9D2D919C6882C0799 /* AutoPresets */, B42D124228D371C400E43D22 /* AlertMuter.swift */, 1D6B1B6626866D89009AC446 /* AlertPermissionsChecker.swift */, 439897361CD2F80600223065 /* AnalyticsServicesManager.swift */, B4F3D25024AF890C0095CE44 /* BluetoothStateManager.swift */, @@ -3378,7 +3442,15 @@ isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( - C17824A51E1AD4D100D9D25C /* ManualBolusRecommendation.swift in Sources */, + + B9D78BD57F35DBCFEC65530D /* AutoPresets_Coordinator.swift in Sources */, + BACF15F57C2B75D78B797778 /* AutoPresets_Delegate.swift in Sources */, + B1F90AD96E591453CF003AC2 /* AutoPresets_Logger.swift in Sources */, + 211A65C85F6FEC59ED083F2D /* AutoPresets_Storage.swift in Sources */, + 0556C20E91536D540ED3D91E /* AutoPresets_ActivityDetectionManager.swift in Sources */, + FFF05E4789A2D06526EB0A24 /* AutoPresets_SettingsView.swift in Sources */, + 232E357E321361C66498FEEA /* AutoPresets_Models.swift in Sources */, + 13992ED79F79937110B6BFCD /* AutoPresets_FeatureFlags.swift in Sources */, C17824A51E1AD4D100D9D25C /* ManualBolusRecommendation.swift in Sources */, 897A5A9624C2175B00C4E71D /* BolusEntryView.swift in Sources */, 4F70C2131DE90339006380B7 /* StatusExtensionContext.swift in Sources */, A9A056B324B93C62007CF06D /* CriticalEventLogExportView.swift in Sources */, diff --git a/Loop/Managers/AutoPresets/AutoPresets_ActivityDetectionManager.swift b/Loop/Managers/AutoPresets/AutoPresets_ActivityDetectionManager.swift new file mode 100644 index 0000000000..e43a502700 --- /dev/null +++ b/Loop/Managers/AutoPresets/AutoPresets_ActivityDetectionManager.swift @@ -0,0 +1,542 @@ +// +// AutoPresets_ActivityDetectionManager.swift +// Loop +// +// AutoPresets — CoreMotion-based activity detection for auto-preset activation. +// +// Idea by Taylor Patterson. Coded by Claude Code. +// Copyright © 2026 LoopKit Authors. All rights reserved. +// + +import CoreMotion +import Foundation +import os.log + +// MARK: - Internal Delegate Protocol + +/// Internal protocol for activity detection callbacks +protocol AutoPresets_ActivityDetectionDelegate: AnyObject { + func activityDetectionDidConfirm(_ activity: AutoPresetsActivityType) + func activityDetectionDidStop(_ activity: AutoPresetsActivityType) + func activityDetectionDidEncounterError(_ error: AutoPresetsDetectionError) +} + +// MARK: - Activity Detection Manager + +/// Manages CoreMotion-based activity detection for auto-preset activation. +/// +/// Detection flow (pedometer-first): +/// 1. Pedometer live updates count steps continuously +/// 2. When 20+ steps accumulate → start Continuous Activity Time timer +/// 3. Activity classifier determines type (walking vs running) for preset selection +/// 4. When timer fires → query pedometer for additional steps since threshold +/// 5. If steps still accumulating → confirm activity and notify delegate +class AutoPresets_ActivityDetectionManager { + + // MARK: - Constants + + /// Number of steps required before starting the activity timer + private let stepThreshold = 20 + + // MARK: - Properties + + private let log = OSLog(subsystem: "com.loopkit.Loop.AutoPresets", category: "ActivityDetection") + private let fileLog = AutoPresets_Logger.shared + private let stateQueue = DispatchQueue(label: "com.loopkit.AutoPresets.ActivityDetection.state", qos: .utility) + + weak var delegate: AutoPresets_ActivityDetectionDelegate? + + private let pedometer = CMPedometer() + private let motionActivityManager = CMMotionActivityManager() + + // Thread-safe state variables + private var _isMonitoring = false + private var _currentActivity: AutoPresetsActivityType? + private var _detectedActivityType: AutoPresetsActivityType? + private var _stepThresholdReachedTime: Date? + private var _pedometerStartTime: Date? + private var _totalSteps: Int = 0 + private var _lastStepChangeTime: Date? + private var _lastClassifierTime: Date? + + private var isMonitoring: Bool { + get { stateQueue.sync { _isMonitoring } } + set { stateQueue.sync { _isMonitoring = newValue } } + } + + private var currentActivity: AutoPresetsActivityType? { + get { stateQueue.sync { _currentActivity } } + set { stateQueue.sync { _currentActivity = newValue } } + } + + // MARK: - Configuration + + var supportedActivities: Set = [.walking] + var activityStopInterval: TimeInterval = 300 + var continuousActivityTime: TimeInterval = 30 + var requireHighConfidence: Bool = false + + // Thread-safe timer references + private var _continuousActivityTimer: Timer? + private var _activityStopTimer: Timer? + + // MARK: - Public Properties + + var detectedActivity: AutoPresetsActivityType? { + currentActivity + } + + var isActivityDetected: Bool { + currentActivity != nil + } + + // MARK: - Initialization + + init() { + os_log("AutoPresets_ActivityDetectionManager initialized", log: log, type: .debug) + } + + deinit { + os_log("AutoPresets_ActivityDetectionManager deinitializing", log: log, type: .debug) + stopMonitoring() + cleanupTimers() + } + + // MARK: - Public Methods + + func startMonitoring() { + guard !isMonitoring else { + os_log("Activity detection already monitoring", log: log, type: .debug) + return + } + + // Check device capability + guard CMPedometer.isStepCountingAvailable(), CMMotionActivityManager.isActivityAvailable() else { + os_log("Motion detection not available on this device", log: log, type: .error) + delegate?.activityDetectionDidEncounterError(.motionNotAvailable) + return + } + + // Check authorization status + let authorizationStatus = CMMotionActivityManager.authorizationStatus() + switch authorizationStatus { + case .notDetermined: + break + case .denied, .restricted: + os_log("Motion & Fitness permission denied or restricted", log: log, type: .error) + delegate?.activityDetectionDidEncounterError(.permissionDenied) + return + case .authorized: + break + @unknown default: + os_log("Unknown motion authorization status", log: log, type: .error) + delegate?.activityDetectionDidEncounterError(.permissionDenied) + return + } + + isMonitoring = true + startPedometerUpdates() + startMotionActivityUpdates() + + os_log( + "Started activity detection - supported: %{public}@, continuous activity time: %.0fs, stop delay: %.0fs", + log: log, + type: .info, + supportedActivities.map(\.displayName).joined(separator: ", "), + continuousActivityTime, + activityStopInterval + ) + fileLog.log("Started monitoring - continuousActivityTime: \(continuousActivityTime)s, stopInterval: \(activityStopInterval)s") + } + + func stopMonitoring() { + guard isMonitoring else { return } + + isMonitoring = false + pedometer.stopUpdates() + motionActivityManager.stopActivityUpdates() + cleanupTimers() + + if let activity = currentActivity { + currentActivity = nil + delegate?.activityDetectionDidStop(activity) + } + + stateQueue.sync { + _detectedActivityType = nil + _stepThresholdReachedTime = nil + _pedometerStartTime = nil + _totalSteps = 0 + _lastStepChangeTime = nil + _lastClassifierTime = nil + } + + os_log("Stopped activity detection monitoring", log: log, type: .info) + } + + // MARK: - Pedometer (Phase 1: Step Detection) + + private func startPedometerUpdates() { + let startDate = Date() + stateQueue.sync { + _pedometerStartTime = startDate + _totalSteps = 0 + _stepThresholdReachedTime = nil + _lastStepChangeTime = nil + } + + fileLog.log("Pedometer started from: \(startDate)") + + pedometer.startUpdates(from: startDate) { [weak self] pedometerData, error in + guard let self = self, self.isMonitoring else { return } + + if let error = error { + os_log("Pedometer error: %{public}@", log: self.log, type: .error, error.localizedDescription) + self.fileLog.log("Pedometer ERROR: \(error.localizedDescription)") + return + } + + guard let data = pedometerData else { + self.fileLog.log("Pedometer callback with nil data") + return + } + + let steps = data.numberOfSteps.intValue + self.fileLog.log("Pedometer update: \(steps) steps") + + DispatchQueue.main.async { [weak self] in + self?.processPedometerUpdate(totalSteps: steps) + } + } + } + + private func processPedometerUpdate(totalSteps: Int) { + fileLog.log("Processing pedometer: \(totalSteps) steps (threshold: \(stepThreshold))") + + let (shouldStartTimer, alreadyConfirmed, stepsChanged) = stateQueue.sync { () -> (Bool, Bool, Bool) in + let previousSteps = _totalSteps + _totalSteps = totalSteps + let changed = totalSteps != previousSteps + + // Track when steps last changed (for recency check at confirmation) + if changed { + _lastStepChangeTime = Date() + } + + // Already confirmed — only care if steps actually changed + guard _currentActivity == nil else { + return (false, true, changed) + } + + // Check if we just crossed the step threshold + if totalSteps >= stepThreshold && _stepThresholdReachedTime == nil { + _stepThresholdReachedTime = Date() + return (true, false, changed) + } + + return (false, false, changed) + } + + if alreadyConfirmed { + if stepsChanged { + startActivityStopTimer() + } + return + } + + if shouldStartTimer { + // Determine activity type from classifier, default to walking + let activityType = stateQueue.sync { _detectedActivityType } ?? .walking + + os_log( + "Step threshold reached (%{public}d steps) - starting continuous activity timer (%.0fs) for %{public}@", + log: log, + type: .info, + totalSteps, + continuousActivityTime, + activityType.displayName + ) + fileLog.log("Step threshold reached (\(totalSteps) steps) - starting \(continuousActivityTime)s timer for \(activityType.displayName)") + + startContinuousActivityTimer(for: activityType) + } + } + + // MARK: - Activity Classifier (determines walking vs running) + + private func startMotionActivityUpdates() { + let queue = OperationQueue() + queue.name = "AutoPresetsActivityClassifierQueue" + queue.qualityOfService = .utility + queue.maxConcurrentOperationCount = 1 + + motionActivityManager.startActivityUpdates(to: queue) { [weak self] activity in + guard let self = self, self.isMonitoring else { return } + guard let activity = activity else { return } + + // Filter stale updates + guard Date().timeIntervalSince(activity.startDate) < 300 else { return } + + // Check confidence + let acceptable: Bool + if self.requireHighConfidence { + acceptable = activity.confidence == .high + } else { + acceptable = activity.confidence == .high || activity.confidence == .medium + } + guard acceptable else { return } + + // Determine activity type + var type: AutoPresetsActivityType? + if self.supportedActivities.contains(.walking), activity.walking, + !activity.automotive, !activity.cycling + { + type = .walking + } else if self.supportedActivities.contains(.running), activity.running, + !activity.automotive, !activity.cycling + { + type = .running + } + + if let type = type { + self.stateQueue.sync { + self._detectedActivityType = type + self._lastClassifierTime = Date() + } + } else { + // Non-target activity detected — may need to trigger stop + let shouldStop = activity.confidence != .low && + (activity.automotive || activity.cycling) + + if shouldStop { + DispatchQueue.main.async { [weak self] in + self?.handleNonTargetActivity() + } + } + } + } + } + + private func handleNonTargetActivity() { + let shouldStartStopTimer = stateQueue.sync { () -> Bool in + _currentActivity != nil && _activityStopTimer == nil + } + + if shouldStartStopTimer { + os_log("Non-target activity detected (automotive/cycling), starting stop timer", log: log, type: .debug) + startActivityStopTimer() + } + } + + // MARK: - Continuous Activity Timer (Phase 2: Sustained Activity Check) + + private func startContinuousActivityTimer(for activity: AutoPresetsActivityType) { + os_log( + "Starting continuous activity timer with interval: %.0fs (setting value: %.0fs)", + log: log, + type: .debug, + continuousActivityTime, + continuousActivityTime + ) + fileLog.log("Timer created with interval: \(continuousActivityTime)s") + + stateQueue.sync { + _continuousActivityTimer?.invalidate() + _continuousActivityTimer = nil + } + + let stepsAtThreshold = stateQueue.sync { _totalSteps } + let timerInterval = continuousActivityTime // Capture the value + let timerStartTime = Date() + + let newTimer = Timer(timeInterval: timerInterval, repeats: false) { [weak self] timer in + guard let self = self else { + timer.invalidate() + return + } + + let elapsed = Date().timeIntervalSince(timerStartTime) + os_log( + "Continuous activity timer fired - expected: %.0fs, actual elapsed: %.1fs", + log: self.log, + type: .debug, + timerInterval, + elapsed + ) + self.fileLog.log("Timer FIRED - expected: \(timerInterval)s, actual elapsed: \(String(format: "%.1f", elapsed))s") + + guard self.isMonitoring else { + timer.invalidate() + return + } + + // Check if steps increased since the threshold was reached + let (currentSteps, thresholdTime, lastStepTime, classifierType, classifierTime) = self.stateQueue.sync { () -> (Int, Date?, Date?, AutoPresetsActivityType?, Date?) in + return (self._totalSteps, self._stepThresholdReachedTime, self._lastStepChangeTime, self._detectedActivityType, self._lastClassifierTime) + } + + let additionalSteps = currentSteps - stepsAtThreshold + + let minAdditionalSteps = max(15, Int(elapsed / 60.0 * 30.0)) + + let timerDelay = max(0, elapsed - timerInterval) + let stepRecencyLimit: TimeInterval = 30 + timerDelay + let now = Date() + let stepIsRecent: Bool + if let lastStep = lastStepTime { + let sinceLast = now.timeIntervalSince(lastStep) + stepIsRecent = sinceLast <= stepRecencyLimit + self.fileLog.log("Recency check: last step change \(String(format: "%.1f", sinceLast))s ago (limit: \(String(format: "%.0f", stepRecencyLimit))s = 30s base + \(String(format: "%.0f", timerDelay))s timer delay) → \(stepIsRecent ? "PASS" : "FAIL")") + } else { + stepIsRecent = false + self.fileLog.log("Recency check: no step changes recorded → FAIL") + } + + let classifierConfirmed: Bool + if self.requireHighConfidence { + let classifierRecencyLimit: TimeInterval = 60 + if let cType = classifierType, let cTime = classifierTime { + let sinceClassifier = now.timeIntervalSince(cTime) + classifierConfirmed = sinceClassifier <= classifierRecencyLimit + self.fileLog.log("Classifier check (high confidence required): \(cType.displayName) confirmed \(String(format: "%.1f", sinceClassifier))s ago (limit: \(classifierRecencyLimit)s) → \(classifierConfirmed ? "PASS" : "FAIL")") + } else { + classifierConfirmed = false + self.fileLog.log("Classifier check (high confidence required): no classifier data → FAIL") + } + } else { + classifierConfirmed = true + } + + if additionalSteps >= minAdditionalSteps && stepIsRecent && classifierConfirmed { + let activityType = classifierType ?? activity + + os_log( + "%{public}@ confirmed after %.1fs - %{public}d total steps (%{public}d additional since threshold)", + log: self.log, + type: .info, + activityType.displayName, + elapsed, + currentSteps, + additionalSteps + ) + self.fileLog.log("CONFIRMED \(activityType.displayName) after \(String(format: "%.1f", elapsed))s - \(currentSteps) total steps (\(additionalSteps) additional)") + + self.stateQueue.sync { + self._currentActivity = activityType + self._continuousActivityTimer = nil + } + self.delegate?.activityDetectionDidConfirm(activityType) + + self.startActivityStopTimer() + } else { + let reason: String + if !stepIsRecent { + reason = "user stopped walking before timer fired" + } else if !classifierConfirmed { + reason = "CoreMotion classifier did not confirm activity at high confidence" + } else { + reason = "only \(additionalSteps) additional steps (need >= \(minAdditionalSteps))" + } + os_log( + "%{public}@ confirmation failed - %{public}@", + log: self.log, + type: .debug, + activity.displayName, + reason + ) + self.fileLog.log("REJECTED \(activity.displayName) - \(reason) in \(String(format: "%.0f", elapsed))s") + + self.stateQueue.sync { + self._stepThresholdReachedTime = nil + self._continuousActivityTimer = nil + } + + self.resetPedometer() + } + + timer.invalidate() + } + + stateQueue.sync { + _continuousActivityTimer = newTimer + } + RunLoop.main.add(newTimer, forMode: .common) + } + + // MARK: - Stop Detection + + private func startActivityStopTimer() { + stateQueue.sync { + _activityStopTimer?.invalidate() + _activityStopTimer = nil + } + + let newTimer = Timer(timeInterval: activityStopInterval, repeats: false) { [weak self] timer in + guard let self = self else { + timer.invalidate() + return + } + + guard self.isMonitoring else { + timer.invalidate() + return + } + + let activityToStop = self.stateQueue.sync { () -> AutoPresetsActivityType? in + let activity = self._currentActivity + self._currentActivity = nil + self._stepThresholdReachedTime = nil + self._activityStopTimer = nil + return activity + } + + if let activity = activityToStop { + self.delegate?.activityDetectionDidStop(activity) + os_log( + "%{public}@ stopped after %.0fs of inactivity", + log: self.log, + type: .info, + activity.displayName, + self.activityStopInterval + ) + self.fileLog.log("DEACTIVATED \(activity.displayName) after \(self.activityStopInterval)s of no steps") + } + + self.resetPedometer() + + timer.invalidate() + } + + stateQueue.sync { + _activityStopTimer = newTimer + } + RunLoop.main.add(newTimer, forMode: .common) + } + + // MARK: - Helpers + + private func cleanupTimers() { + stateQueue.sync { + _continuousActivityTimer?.invalidate() + _continuousActivityTimer = nil + _activityStopTimer?.invalidate() + _activityStopTimer = nil + } + } + + private func resetPedometer() { + pedometer.stopUpdates() + + stateQueue.sync { + _totalSteps = 0 + _stepThresholdReachedTime = nil + _pedometerStartTime = nil + _lastStepChangeTime = nil + } + + // Restart pedometer for next detection cycle + if isMonitoring { + startPedometerUpdates() + } + } +} diff --git a/Loop/Managers/AutoPresets/AutoPresets_Coordinator.swift b/Loop/Managers/AutoPresets/AutoPresets_Coordinator.swift new file mode 100644 index 0000000000..f8b3e3b627 --- /dev/null +++ b/Loop/Managers/AutoPresets/AutoPresets_Coordinator.swift @@ -0,0 +1,314 @@ +// +// AutoPresets_Coordinator.swift +// Loop +// +// AutoPresets — Main entry point. Coordinates activity detection and preset activation. +// +// Idea by Taylor Patterson. Coded by Claude Code. +// Copyright © 2026 LoopKit Authors. All rights reserved. +// + +import Combine +import Foundation +import LoopKit +import os.log + +// MARK: - AutoPresets Coordinator + +/// Main entry point for AutoPresets feature +/// Coordinates activity detection and preset activation with minimal coupling to Loop +public class AutoPresets_Coordinator: ObservableObject { + + // MARK: - Singleton + + public static let shared = AutoPresets_Coordinator() + + // MARK: - Published Properties + + @Published public private(set) var isMonitoring: Bool = false + @Published public private(set) var currentDetectedActivity: AutoPresetsActivityType? + @Published public private(set) var lastError: AutoPresetsDetectionError? + + // MARK: - Private Properties + + private let log = OSLog(subsystem: "com.loopkit.Loop.AutoPresets", category: "Coordinator") + private let storage = AutoPresets_Storage() + private let activityDetectionManager = AutoPresets_ActivityDetectionManager() + + // Debounce/guard properties to prevent rapid restarts + private var isUpdatingSettings = false + private var pendingRestart: DispatchWorkItem? + + public weak var delegate: AutoPresets_Delegate? { + didSet { + // Start monitoring when delegate is set (if not already running) + if delegate != nil && !isMonitoring { + startIfConfigured() + } + } + } + + // Track which preset we activated so we can deactivate the same one + private var activatedPresetId: UUID? + + // MARK: - Public Settings Access + + /// Current settings (read-only access) + public var settings: AutoPresetsSettings { + storage.settings + } + + /// Whether the feature is enabled + public var isEnabled: Bool { + get { storage.settings.isEnabled } + set { + // Skip if no change + guard newValue != storage.settings.isEnabled else { return } + + objectWillChange.send() + storage.updateSettings { $0.isEnabled = newValue } + if newValue { + startIfConfigured() + } else { + stop() + } + logEvent(newValue ? .featureEnabled : .featureDisabled) + } + } + + // MARK: - Initialization + + private init() { + activityDetectionManager.delegate = self + + // Perform migration from legacy settings if needed + storage.migrateFromLegacyIfNeeded() + + // Note: Monitoring starts when delegate is set (see delegate didSet) + os_log("AutoPresets_Coordinator initialized", log: log, type: .debug) + } + + // MARK: - Public Methods + + /// Update settings with a closure + public func updateSettings(_ update: (inout AutoPresetsSettings) -> Void) { + // Guard against re-entrancy + guard !isUpdatingSettings else { return } + isUpdatingSettings = true + defer { isUpdatingSettings = false } + + objectWillChange.send() + storage.updateSettings(update) + applySettingsToDetectionManager() + + // Debounce restart to prevent rapid cycling + pendingRestart?.cancel() + if isMonitoring { + let workItem = DispatchWorkItem { [weak self] in + guard let self = self else { return } + self.stop() + self.startIfConfigured() + } + pendingRestart = workItem + DispatchQueue.main.asyncAfter(deadline: .now() + 0.5, execute: workItem) + } + } + + /// Get the preset for an activity type + public func preset(for activity: AutoPresetsActivityType) -> TemporaryScheduleOverridePreset? { + guard let presetId = settings.presetId(for: activity), + let delegate = delegate + else { + return nil + } + + return delegate.autoPresetsAvailablePresets(self).first { $0.id == presetId } + } + + /// Set the preset for an activity type + public func setPreset(_ preset: TemporaryScheduleOverridePreset?, for activity: AutoPresetsActivityType) { + objectWillChange.send() + storage.updateSettings { settings in + settings.setPresetId(preset?.id, for: activity) + } + } + + /// Get all available presets from Loop + public func availablePresets() -> [TemporaryScheduleOverridePreset] { + delegate?.autoPresetsAvailablePresets(self) ?? [] + } + + /// Get the current override from Loop + public func currentOverride() -> TemporaryScheduleOverride? { + delegate?.autoPresetsCurrentOverride(self) + } + + /// Start monitoring (if configured properly) + public func startIfConfigured() { + // Prevent starting if already monitoring + guard !isMonitoring else { + os_log("AutoPresets already monitoring, skipping start", log: log, type: .debug) + return + } + + guard delegate != nil else { + os_log("AutoPresets delegate not set, not starting", log: log, type: .debug) + return + } + + guard settings.isEnabled else { + os_log("AutoPresets not enabled, not starting", log: log, type: .debug) + return + } + + guard settings.hasConfiguredPresets else { + os_log("AutoPresets has no configured presets, not starting", log: log, type: .debug) + return + } + + applySettingsToDetectionManager() + activityDetectionManager.startMonitoring() + isMonitoring = true + + os_log( + "AutoPresets monitoring started - activities: %{public}@, continuous activity time: %.0fs, stop: %.0fs", + log: log, + type: .info, + settings.supportedActivityTypes.map(\.displayName).joined(separator: ", "), + settings.continuousActivityTime, + settings.stopInterval + ) + } + + /// Stop monitoring + public func stop() { + activityDetectionManager.stopMonitoring() + isMonitoring = false + currentDetectedActivity = nil + + os_log("AutoPresets monitoring stopped", log: log, type: .info) + } + + /// Clear the last error + public func clearError() { + lastError = nil + } + + /// Clear all activity log entries + public func clearActivityLog() { + objectWillChange.send() + storage.clearActivityLog() + } + + // MARK: - Private Methods + + private func applySettingsToDetectionManager() { + let currentSettings = settings + + activityDetectionManager.supportedActivities = currentSettings.supportedActivityTypes + activityDetectionManager.activityStopInterval = currentSettings.stopInterval + activityDetectionManager.continuousActivityTime = currentSettings.continuousActivityTime + activityDetectionManager.requireHighConfidence = currentSettings.requireHighConfidence + } + + private func logEvent(_ event: AutoPresetsLogEvent, activity: AutoPresetsActivityType? = nil, presetName: String? = nil) { + storage.addLogEntry(event: event, activityType: activity, presetName: presetName) + } + + private func activatePreset(for activity: AutoPresetsActivityType) { + guard let preset = preset(for: activity) else { + os_log( + "No preset configured for %{public}@", + log: log, + type: .error, + activity.displayName + ) + return + } + + // Check if there's already an active override that wasn't started by us + if let currentOverride = currentOverride(), activatedPresetId == nil { + os_log( + "Override already active (not from AutoPresets), skipping activation", + log: log, + type: .info + ) + return + } + + activatedPresetId = preset.id + delegate?.autoPresets(self, shouldActivatePreset: preset) + logEvent(.presetActivated, activity: activity, presetName: preset.name) + + os_log( + "Activated preset '%{public}@' for %{public}@", + log: log, + type: .info, + preset.name, + activity.displayName + ) + } + + private func deactivatePreset(for activity: AutoPresetsActivityType) { + guard let presetId = activatedPresetId, + let preset = availablePresets().first(where: { $0.id == presetId }) + else { + os_log( + "No AutoPresets-activated preset to deactivate", + log: log, + type: .debug + ) + activatedPresetId = nil + return + } + + activatedPresetId = nil + delegate?.autoPresets(self, shouldDeactivatePreset: preset) + logEvent(.presetDeactivated, activity: activity, presetName: preset.name) + + os_log( + "Deactivated preset '%{public}@' for %{public}@", + log: log, + type: .info, + preset.name, + activity.displayName + ) + } +} + +// MARK: - AutoPresets_ActivityDetectionDelegate + +extension AutoPresets_Coordinator: AutoPresets_ActivityDetectionDelegate { + + func activityDetectionDidConfirm(_ activity: AutoPresetsActivityType) { + DispatchQueue.main.async { [weak self] in + guard let self = self else { return } + + self.currentDetectedActivity = activity + self.activatePreset(for: activity) + } + } + + func activityDetectionDidStop(_ activity: AutoPresetsActivityType) { + DispatchQueue.main.async { [weak self] in + guard let self = self else { return } + + self.currentDetectedActivity = nil + self.deactivatePreset(for: activity) + } + } + + func activityDetectionDidEncounterError(_ error: AutoPresetsDetectionError) { + DispatchQueue.main.async { [weak self] in + guard let self = self else { return } + + self.lastError = error + os_log( + "Activity detection error: %{public}@", + log: self.log, + type: .error, + error.localizedDescription + ) + } + } +} diff --git a/Loop/Managers/AutoPresets/AutoPresets_Delegate.swift b/Loop/Managers/AutoPresets/AutoPresets_Delegate.swift new file mode 100644 index 0000000000..bcb02a2a73 --- /dev/null +++ b/Loop/Managers/AutoPresets/AutoPresets_Delegate.swift @@ -0,0 +1,29 @@ +// +// AutoPresets_Delegate.swift +// Loop +// +// AutoPresets — Protocol that Loop implements to receive commands from AutoPresets. +// +// Idea by Taylor Patterson. Coded by Claude Code. +// Copyright © 2026 LoopKit Authors. All rights reserved. +// + +import Foundation +import LoopKit + +/// Protocol that Loop implements to receive commands from AutoPresets +public protocol AutoPresets_Delegate: AnyObject { + /// Called when AutoPresets wants to activate a preset + func autoPresets(_ coordinator: AutoPresets_Coordinator, + shouldActivatePreset preset: TemporaryScheduleOverridePreset) + + /// Called when AutoPresets wants to deactivate the current preset + func autoPresets(_ coordinator: AutoPresets_Coordinator, + shouldDeactivatePreset preset: TemporaryScheduleOverridePreset) + + /// Returns currently available override presets from Loop + func autoPresetsAvailablePresets(_ coordinator: AutoPresets_Coordinator) -> [TemporaryScheduleOverridePreset] + + /// Returns the currently active override, if any + func autoPresetsCurrentOverride(_ coordinator: AutoPresets_Coordinator) -> TemporaryScheduleOverride? +} diff --git a/Loop/Managers/AutoPresets/AutoPresets_Logger.swift b/Loop/Managers/AutoPresets/AutoPresets_Logger.swift new file mode 100644 index 0000000000..3c67f5123f --- /dev/null +++ b/Loop/Managers/AutoPresets/AutoPresets_Logger.swift @@ -0,0 +1,163 @@ +// +// AutoPresets_Logger.swift +// Loop +// +// AutoPresets — Simple file-based logger for debugging. +// +// Idea by Taylor Patterson. Coded by Claude Code. +// Copyright © 2026 LoopKit Authors. All rights reserved. +// + +import Foundation + +/// Simple file-based logger for AutoPresets debugging +/// Logs are written to Documents/AutoPresetsLog.txt +public class AutoPresets_Logger { + + // MARK: - Singleton + + public static let shared = AutoPresets_Logger() + + // MARK: - Properties + + private let fileManager = FileManager.default + private let logFileName = "AutoPresetsLog.txt" + private let maxLogSize = 100_000 // ~100KB max before truncating old entries + private let queue = DispatchQueue(label: "com.loopkit.AutoPresets.Logger", qos: .utility) + + private var logFileURL: URL? { + guard let documentsURL = fileManager.urls(for: .documentDirectory, in: .userDomainMask).first else { + return nil + } + return documentsURL.appendingPathComponent(logFileName) + } + + // MARK: - Initialization + + private init() { + // Create log file if it doesn't exist + if let url = logFileURL, !fileManager.fileExists(atPath: url.path) { + fileManager.createFile(atPath: url.path, contents: nil, attributes: nil) + } + } + + // MARK: - Public Methods + + /// Whether debug logging is enabled (checked from settings) + public var isEnabled: Bool { + AutoPresets_Storage().settings.debugLoggingEnabled + } + + /// Log a message with timestamp (only if debug logging is enabled) + public func log(_ message: String, function: String = #function) { + guard isEnabled else { return } + queue.async { [weak self] in + self?.writeLog(message, function: function) + } + } + + /// Get the full log contents + public func getLogContents() -> String { + guard let url = logFileURL, + let contents = try? String(contentsOf: url, encoding: .utf8) + else { + return "(No logs available)" + } + return contents + } + + /// Clear all logs + public func clearLogs() { + queue.async { [weak self] in + guard let self = self, let url = self.logFileURL else { return } + try? "".write(to: url, atomically: true, encoding: .utf8) + } + } + + /// Get the log file URL (for sharing) + public func getLogFileURL() -> URL? { + return logFileURL + } + + // MARK: - Private Methods + + private func writeLog(_ message: String, function: String) { + guard let url = logFileURL else { return } + + let dateFormatter = DateFormatter() + dateFormatter.dateFormat = "yyyy-MM-dd HH:mm:ss.SSS" + let timestamp = dateFormatter.string(from: Date()) + + let logEntry = "[\(timestamp)] \(function): \(message)\n" + + // Append to file + if let handle = try? FileHandle(forWritingTo: url) { + handle.seekToEndOfFile() + if let data = logEntry.data(using: .utf8) { + handle.write(data) + } + handle.closeFile() + } + + // Truncate if too large + truncateIfNeeded() + } + + private func truncateIfNeeded() { + guard let url = logFileURL, + let contents = try? String(contentsOf: url, encoding: .utf8), + !contents.isEmpty + else { + return + } + + // Remove entries older than 5 days + let fiveDaysAgo = Date().addingTimeInterval(-5 * 24 * 60 * 60) + let lines = contents.components(separatedBy: "\n") + var filteredLines: [String] = [] + + let dateFormatter = DateFormatter() + dateFormatter.dateFormat = "yyyy-MM-dd HH:mm:ss.SSS" + + for line in lines { + guard !line.isEmpty else { continue } + + // Parse timestamp from line format: [2024-01-15 10:30:45.123] ... + if line.hasPrefix("["), + let closingBracket = line.firstIndex(of: "]"), + closingBracket > line.index(line.startIndex, offsetBy: 1) { + let timestampStart = line.index(after: line.startIndex) + let timestampString = String(line[timestampStart..= fiveDaysAgo { + filteredLines.append(line) + } + } else { + // Keep lines we can't parse + filteredLines.append(line) + } + } else { + // Keep lines without proper timestamp format + filteredLines.append(line) + } + } + + var newContents = filteredLines.joined(separator: "\n") + if !newContents.isEmpty && !newContents.hasSuffix("\n") { + newContents += "\n" + } + + // Also apply size limit if still too large + if newContents.count > maxLogSize { + let keepFrom = newContents.index(newContents.endIndex, offsetBy: -50_000, limitedBy: newContents.startIndex) ?? newContents.startIndex + newContents = "[...truncated...]\n" + String(newContents[keepFrom...]) + } + + // Only write if we actually removed something + if newContents.count < contents.count { + try? newContents.write(to: url, atomically: true, encoding: .utf8) + } + } +} diff --git a/Loop/Managers/AutoPresets/AutoPresets_Storage.swift b/Loop/Managers/AutoPresets/AutoPresets_Storage.swift new file mode 100644 index 0000000000..66b8f7a970 --- /dev/null +++ b/Loop/Managers/AutoPresets/AutoPresets_Storage.swift @@ -0,0 +1,199 @@ +// +// AutoPresets_Storage.swift +// Loop +// +// AutoPresets — Isolated persistence using its own UserDefaults suite. +// +// Idea by Taylor Patterson. Coded by Claude Code. +// Copyright © 2026 LoopKit Authors. All rights reserved. +// + +import Foundation +import os.log + +/// Isolated persistence for AutoPresets using its own UserDefaults suite +public class AutoPresets_Storage { + + private let log = OSLog(subsystem: "com.loopkit.Loop.AutoPresets", category: "Storage") + + // MARK: - Constants + + /// Separate UserDefaults suite - not Loop's main UserDefaults + private static let suiteName = "com.loopkit.Loop.AutoPresets" + private static let settingsKey = "settings" + private static let migrationKey = "didMigrateFromLegacy" + + // MARK: - Properties + + private let defaults: UserDefaults + + // MARK: - Initialization + + public init() { + self.defaults = UserDefaults(suiteName: Self.suiteName) ?? .standard + } + + // MARK: - Settings Access + + /// Current settings (reads from UserDefaults) + public var settings: AutoPresetsSettings { + get { + guard let data = defaults.data(forKey: Self.settingsKey), + let settings = try? JSONDecoder().decode(AutoPresetsSettings.self, from: data) + else { + return AutoPresetsSettings() + } + return settings + } + set { + if let data = try? JSONEncoder().encode(newValue) { + defaults.set(data, forKey: Self.settingsKey) + } + } + } + + /// Update settings with a closure + public func updateSettings(_ update: (inout AutoPresetsSettings) -> Void) { + var current = settings + update(¤t) + settings = current + } + + // MARK: - Activity Log + + /// Add a log entry to the activity log + public func addLogEntry(_ entry: AutoPresetsLogEntry) { + updateSettings { settings in + settings.recentActivityLog.insert(entry, at: 0) + if settings.recentActivityLog.count > 20 { + settings.recentActivityLog = Array(settings.recentActivityLog.prefix(20)) + } + } + } + + /// Clear all activity log entries + public func clearActivityLog() { + updateSettings { settings in + settings.recentActivityLog = [] + } + } + + /// Add a log entry with parameters + public func addLogEntry( + event: AutoPresetsLogEvent, + activityType: AutoPresetsActivityType? = nil, + presetName: String? = nil + ) { + let entry = AutoPresetsLogEntry( + date: Date(), + event: event, + activityType: activityType, + presetName: presetName + ) + addLogEntry(entry) + } + + // MARK: - Migration + + /// Whether migration from legacy UserDefaults has been performed + public var didMigrateFromLegacy: Bool { + get { defaults.bool(forKey: Self.migrationKey) } + set { defaults.set(newValue, forKey: Self.migrationKey) } + } + + /// Migrate settings from legacy Loop UserDefaults to new isolated suite + public func migrateFromLegacyIfNeeded() { + guard !didMigrateFromLegacy else { return } + + let legacyDefaults = UserDefaults.standard + + // Read legacy values + let isEnabled = legacyDefaults.bool(forKey: "com.loopkit.Loop.walkingAutoPresetEnabled") + let confirmationInterval = legacyDefaults.double(forKey: "com.loopkit.Loop.walkingConfirmationInterval") + let stopInterval = legacyDefaults.double(forKey: "com.loopkit.Loop.walkingStopInterval") + let continuousWindow = legacyDefaults.double(forKey: "com.loopkit.Loop.autoPresetContinuousActivityWindow") + let requireHighConfidence = legacyDefaults.bool(forKey: "com.loopkit.Loop.autoPresetRequireHighConfidence") + let supportedTypesRaw = legacyDefaults.stringArray(forKey: "com.loopkit.Loop.supportedActivityTypes") ?? ["walking"] + let activityPresetsMap = legacyDefaults.dictionary(forKey: "com.loopkit.Loop.activityPresets") as? [String: String] ?? [:] + + // Migrate activity log + var migratedLog: [AutoPresetsLogEntry] = [] + if let logData = legacyDefaults.data(forKey: "com.loopkit.Loop.recentWalkingActivityLog") { + if let legacyEntries = try? JSONDecoder().decode([LegacyLogEntry].self, from: logData) { + migratedLog = legacyEntries.compactMap { legacy in + guard let event = convertLegacyEvent(legacy.event) else { return nil } + return AutoPresetsLogEntry( + id: UUID(), + date: legacy.date, + event: event, + activityType: legacy.activityType.flatMap { AutoPresetsActivityType(rawValue: $0) }, + presetName: legacy.presetName + ) + } + } + } + + // Convert supported types + let supportedTypes = Set(supportedTypesRaw.compactMap { AutoPresetsActivityType(rawValue: $0) }) + + // Create new settings + var newSettings = AutoPresetsSettings() + newSettings.isEnabled = isEnabled + newSettings.supportedActivityTypes = supportedTypes.isEmpty ? [.walking] : supportedTypes + newSettings.activityPresets = activityPresetsMap + newSettings.stopInterval = stopInterval > 0 ? stopInterval : 300 + newSettings.continuousActivityTime = continuousWindow > 0 ? continuousWindow : 30 + newSettings.requireHighConfidence = requireHighConfidence + newSettings.recentActivityLog = migratedLog + + // Save to new suite + settings = newSettings + didMigrateFromLegacy = true + + // If user had AutoPresets enabled previously, enable the feature flag + if isEnabled { + AutoPresets_FeatureFlags.isEnabled = true + } + + os_log( + "Migrated AutoPresets settings - enabled: %{public}@, activities: %{public}@, presets: %{public}@, continuous activity time: %.0fs, stop: %.0fs", + log: log, + type: .info, + isEnabled ? "YES" : "NO", + supportedTypes.map(\.displayName).joined(separator: ", "), + activityPresetsMap.keys.joined(separator: ", "), + newSettings.continuousActivityTime, + newSettings.stopInterval + ) + } + + // MARK: - Reset + + /// Reset all AutoPresets data + public func reset() { + if let bundleId = Bundle.main.bundleIdentifier { + defaults.removePersistentDomain(forName: Self.suiteName) + } + } + + // MARK: - Legacy Migration Helpers + + /// Legacy log entry format for migration + private struct LegacyLogEntry: Codable { + let date: Date + let event: String + let activityType: String? + let presetName: String? + } + + /// Convert legacy event string to new enum + private func convertLegacyEvent(_ legacyEvent: String) -> AutoPresetsLogEvent? { + switch legacyEvent { + case "featureEnabled": return .featureEnabled + case "featureDisabled": return .featureDisabled + case "presetActivated": return .presetActivated + case "presetDeactivated": return .presetDeactivated + default: return nil + } + } +} diff --git a/Loop/Managers/LoopDataManager.swift b/Loop/Managers/LoopDataManager.swift index c9aef285e8..d44583eb59 100644 --- a/Loop/Managers/LoopDataManager.swift +++ b/Loop/Managers/LoopDataManager.swift @@ -126,7 +126,10 @@ final class LoopDataManager { self.automaticDosingStatus = automaticDosingStatus self.trustedTimeOffset = trustedTimeOffset - + + // Set up AutoPresets coordinator delegate + AutoPresets_Coordinator.shared.delegate = self + if #available(iOS 16.2, *) { self.liveActivityManager = LiveActivityManager( glucoseStore: self.glucoseStore, @@ -2612,5 +2615,43 @@ extension LoopDataManager: ServicesManagerDelegate { } } } - + +} + +// MARK: - AutoPresets_Delegate + +extension LoopDataManager: AutoPresets_Delegate { + + func autoPresets(_ coordinator: AutoPresets_Coordinator, + shouldActivatePreset preset: TemporaryScheduleOverridePreset) { + logger.default("AutoPresets activating preset: %{public}@", preset.name) + + mutateSettings { settings in + settings.scheduleOverride = preset.createOverride(enactTrigger: .local) + } + } + + func autoPresets(_ coordinator: AutoPresets_Coordinator, + shouldDeactivatePreset preset: TemporaryScheduleOverridePreset) { + guard let currentOverride = settings.scheduleOverride, + case let .preset(currentPreset) = currentOverride.context, + currentPreset.id == preset.id + else { + return + } + + logger.default("AutoPresets deactivating preset: %{public}@", preset.name) + + mutateSettings { settings in + settings.scheduleOverride = nil + } + } + + func autoPresetsAvailablePresets(_ coordinator: AutoPresets_Coordinator) -> [TemporaryScheduleOverridePreset] { + settings.overridePresets + } + + func autoPresetsCurrentOverride(_ coordinator: AutoPresets_Coordinator) -> TemporaryScheduleOverride? { + settings.scheduleOverride + } } diff --git a/Loop/Models/AutoPresets/AutoPresets_Models.swift b/Loop/Models/AutoPresets/AutoPresets_Models.swift new file mode 100644 index 0000000000..4ad7f40fe3 --- /dev/null +++ b/Loop/Models/AutoPresets/AutoPresets_Models.swift @@ -0,0 +1,228 @@ +// +// AutoPresets_Models.swift +// Loop +// +// AutoPresets — Data models for activity types, settings, log entries, and errors. +// +// Idea by Taylor Patterson. Coded by Claude Code. +// Copyright © 2026 LoopKit Authors. All rights reserved. +// + +import Foundation + +// MARK: - Activity Types + +/// Supported activity types for auto-preset activation +public enum AutoPresetsActivityType: String, Codable, CaseIterable, Hashable { + case walking + case running + + public var displayName: String { + switch self { + case .walking: return "Walking" + case .running: return "Running" + } + } + + public var systemImageName: String { + switch self { + case .walking: return "figure.walk" + case .running: return "figure.run" + } + } +} + +// MARK: - Activity Log Events + +/// Events that can be logged in the activity log +public enum AutoPresetsLogEvent: String, Codable { + case featureEnabled + case featureDisabled + case presetActivated + case presetDeactivated + + public var iconName: String { + switch self { + case .featureEnabled: return "power.circle.fill" + case .featureDisabled: return "power.circle" + case .presetActivated: return "play.circle.fill" + case .presetDeactivated: return "stop.circle.fill" + } + } + + public var displayName: String { + switch self { + case .featureEnabled: return "Feature Enabled" + case .featureDisabled: return "Feature Disabled" + case .presetActivated: return "Preset Activated" + case .presetDeactivated: return "Preset Deactivated" + } + } +} + +// MARK: - Activity Log Entry + +/// A single entry in the activity log +public struct AutoPresetsLogEntry: Codable, Identifiable, Equatable { + public let id: UUID + public let date: Date + public let event: AutoPresetsLogEvent + public let activityType: AutoPresetsActivityType? + public let presetName: String? + + public init( + id: UUID = UUID(), + date: Date = Date(), + event: AutoPresetsLogEvent, + activityType: AutoPresetsActivityType? = nil, + presetName: String? = nil + ) { + self.id = id + self.date = date + self.event = event + self.activityType = activityType + self.presetName = presetName + } +} + +// MARK: - Settings Model + +/// All settings for the AutoPresets feature +public struct AutoPresetsSettings: Codable, Equatable { + /// Whether the feature is enabled + public var isEnabled: Bool + + /// Which activity types are being monitored + public var supportedActivityTypes: Set + + /// Mapping of activity type to preset UUID + public var activityPresets: [String: String] // [ActivityType.rawValue: PresetUUID.uuidString] + + /// How long after activity stops before deactivating preset (seconds) + public var stopInterval: TimeInterval + + /// How long sustained activity must continue after step threshold before confirming (seconds) + public var continuousActivityTime: TimeInterval + + /// Whether to require high confidence motion detection + public var requireHighConfidence: Bool + + /// Whether debug logging is enabled + public var debugLoggingEnabled: Bool + + /// Recent activity log entries + public var recentActivityLog: [AutoPresetsLogEntry] + + public init( + isEnabled: Bool = false, + supportedActivityTypes: Set = [.walking], + activityPresets: [String: String] = [:], + stopInterval: TimeInterval = 300, + continuousActivityTime: TimeInterval = 30, + requireHighConfidence: Bool = false, + debugLoggingEnabled: Bool = false, + recentActivityLog: [AutoPresetsLogEntry] = [] + ) { + self.isEnabled = isEnabled + self.supportedActivityTypes = supportedActivityTypes + self.activityPresets = activityPresets + self.stopInterval = stopInterval + self.continuousActivityTime = continuousActivityTime + self.requireHighConfidence = requireHighConfidence + self.debugLoggingEnabled = debugLoggingEnabled + self.recentActivityLog = recentActivityLog + } + + // MARK: - Backward-Compatible Decoding + + /// Handles decoding from previously saved settings that used old key names + public init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + + isEnabled = (try? container.decode(Bool.self, forKey: .isEnabled)) ?? false + supportedActivityTypes = (try? container.decode(Set.self, forKey: .supportedActivityTypes)) ?? [.walking] + activityPresets = (try? container.decode([String: String].self, forKey: .activityPresets)) ?? [:] + stopInterval = (try? container.decode(TimeInterval.self, forKey: .stopInterval)) ?? 300 + requireHighConfidence = (try? container.decode(Bool.self, forKey: .requireHighConfidence)) ?? false + debugLoggingEnabled = (try? container.decode(Bool.self, forKey: .debugLoggingEnabled)) ?? false + recentActivityLog = (try? container.decode([AutoPresetsLogEntry].self, forKey: .recentActivityLog)) ?? [] + + // Try new key first, fall back to legacy key + if let value = try? container.decode(TimeInterval.self, forKey: .continuousActivityTime) { + continuousActivityTime = value + } else if let legacyValue = try? container.decode(TimeInterval.self, forKey: .legacyContinuousActivityWindow) { + continuousActivityTime = legacyValue + } else { + continuousActivityTime = 30 + } + } + + private enum CodingKeys: String, CodingKey { + case isEnabled + case supportedActivityTypes + case activityPresets + case stopInterval + case continuousActivityTime + case requireHighConfidence + case debugLoggingEnabled + case recentActivityLog + // Legacy keys for backward compatibility + case legacyContinuousActivityWindow = "continuousActivityWindow" + } + + public func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + try container.encode(isEnabled, forKey: .isEnabled) + try container.encode(supportedActivityTypes, forKey: .supportedActivityTypes) + try container.encode(activityPresets, forKey: .activityPresets) + try container.encode(stopInterval, forKey: .stopInterval) + try container.encode(continuousActivityTime, forKey: .continuousActivityTime) + try container.encode(requireHighConfidence, forKey: .requireHighConfidence) + try container.encode(debugLoggingEnabled, forKey: .debugLoggingEnabled) + try container.encode(recentActivityLog, forKey: .recentActivityLog) + } + + // MARK: - Helper Methods + + /// Get the preset UUID for an activity type + public func presetId(for activity: AutoPresetsActivityType) -> UUID? { + guard let uuidString = activityPresets[activity.rawValue] else { return nil } + return UUID(uuidString: uuidString) + } + + /// Set the preset UUID for an activity type + public mutating func setPresetId(_ presetId: UUID?, for activity: AutoPresetsActivityType) { + if let presetId = presetId { + activityPresets[activity.rawValue] = presetId.uuidString + } else { + activityPresets.removeValue(forKey: activity.rawValue) + } + } + + /// Check if at least one supported activity has a preset configured + public var hasConfiguredPresets: Bool { + supportedActivityTypes.contains { activity in + activityPresets[activity.rawValue] != nil + } + } +} + +// MARK: - Detection Errors + +/// Errors that can occur during activity detection +public enum AutoPresetsDetectionError: Error { + case motionNotAvailable + case permissionDenied + case configurationError(String) + + public var localizedDescription: String { + switch self { + case .motionNotAvailable: + return "Motion detection is not available on this device" + case .permissionDenied: + return "Motion & Fitness permissions are required for activity detection" + case .configurationError(let message): + return "Configuration error: \(message)" + } + } +} diff --git a/Loop/Resources/AutoPresets/AutoPresets_FeatureFlags.swift b/Loop/Resources/AutoPresets/AutoPresets_FeatureFlags.swift new file mode 100644 index 0000000000..4625d87d34 --- /dev/null +++ b/Loop/Resources/AutoPresets/AutoPresets_FeatureFlags.swift @@ -0,0 +1,34 @@ +// +// AutoPresets_FeatureFlags.swift +// Loop +// +// AutoPresets — Feature toggle and configuration flags. +// All AutoPresets enable/disable logic lives here. +// +// Idea by Taylor Patterson. Coded by Claude Code. +// Copyright © 2026 LoopKit Authors. All rights reserved. +// + +import Foundation + +// MARK: - Feature Toggle + +/// Central on/off switch for the entire AutoPresets feature. +/// Loop host files check `AutoPresets_FeatureFlags.isEnabled` to gate UI insertion. +enum AutoPresets_FeatureFlags { + /// Master toggle — persisted in UserDefaults. + /// Controls whether AutoPresets appears in Settings. + /// Defaults to false; legacy migration sets true for existing users. + static var isEnabled: Bool { + get { UserDefaults.standard.bool(forKey: Keys.autoPresetsEnabled) } + set { UserDefaults.standard.set(newValue, forKey: Keys.autoPresetsEnabled) } + } +} + +// MARK: - UserDefaults Keys + +extension AutoPresets_FeatureFlags { + enum Keys { + static let autoPresetsEnabled = "com.loopkit.Loop.autoPresetsFeatureEnabled" + } +} diff --git a/Loop/Views/AutoPresets/AutoPresets_SettingsView.swift b/Loop/Views/AutoPresets/AutoPresets_SettingsView.swift new file mode 100644 index 0000000000..9ec565de95 --- /dev/null +++ b/Loop/Views/AutoPresets/AutoPresets_SettingsView.swift @@ -0,0 +1,551 @@ +// +// AutoPresets_SettingsView.swift +// Loop +// +// AutoPresets — Settings UI for configuring activity-based preset automation. +// +// Idea by Taylor Patterson. Coded by Claude Code. +// Copyright © 2026 LoopKit Authors. All rights reserved. +// + +import LoopKit +import SwiftUI +import UIKit + +// MARK: - Main Settings View + +struct AutoPresets_SettingsView: View { + @ObservedObject private var coordinator = AutoPresets_Coordinator.shared + @State private var showingErrorAlert = false + @State private var errorMessage = "" + @State private var showingDebugLogs = false + @State private var debugLogsCopied = false + @State private var debugLogsCleared = false + + var body: some View { + List { + enableSection + + if coordinator.isEnabled { + activityTypeSections + detectionSettingsSection + activityLogSection + debugLogsSection + } + } + .navigationTitle("AutoPresets") + .navigationBarTitleDisplayMode(.inline) + .alert("Configuration Error", isPresented: $showingErrorAlert) { + Button("OK") {} + } message: { + Text(errorMessage) + } + .sheet(isPresented: $showingDebugLogs) { + AutoPresets_DebugLogsView(isPresented: $showingDebugLogs) + } + } + + // MARK: - Enable Section + + private var enableSection: some View { + Section { + VStack(alignment: .leading, spacing: 12) { + HStack(spacing: 6) { + Image(systemName: "figure.walk") + .foregroundColor(Color(red: 76/255, green: 175/255, blue: 80/255)) + Text("AUTOPRESETS") + .font(.caption) + .fontWeight(.semibold) + .foregroundColor(.secondary) + .textCase(.uppercase) + .lineLimit(1) + .layoutPriority(1) + } + Toggle(isOn: Binding( + get: { coordinator.isEnabled }, + set: { enabled in + if enabled { + guard !coordinator.availablePresets().isEmpty else { + showErrorAlert("Please create at least one preset before enabling AutoPresets.") + return + } + } + coordinator.isEnabled = enabled + } + )) { + VStack(alignment: .leading) { + Text("Enable AutoPresets") + .font(.headline) + Text("Automatically activates a preset when motion is detected.") + .font(.subheadline) + .foregroundColor(.secondary) + } + } + } + } + } + + // MARK: - Activity Type Sections + + private var activityTypeSections: some View { + ForEach(AutoPresetsActivityType.allCases, id: \.self) { activityType in + Section { + activityTypeRow(for: activityType) + + if coordinator.settings.supportedActivityTypes.contains(activityType) { + presetSelectionView(for: activityType) + } + } + } + } + + private func activityTypeRow(for activityType: AutoPresetsActivityType) -> some View { + HStack { + Image(systemName: activityType.systemImageName) + .foregroundColor(coordinator.settings.supportedActivityTypes.contains(activityType) ? .blue : .secondary) + .frame(width: 24) + + VStack(alignment: .leading) { + Text(activityType.displayName) + .font(.headline) + Text("Detect \(activityType.displayName.lowercased()) activity") + .font(.caption) + .foregroundColor(.secondary) + } + + Spacer() + + Toggle("", isOn: activityToggleBinding(for: activityType)) + } + .contentShape(Rectangle()) + .onTapGesture { + toggleActivityType(activityType) + } + } + + private func presetSelectionView(for activityType: AutoPresetsActivityType) -> some View { + VStack(alignment: .leading, spacing: 8) { + Text("Select your preset for \(activityType.displayName)") + .font(.subheadline) + .fontWeight(.medium) + .foregroundColor(.secondary) + .padding(.top, 8) + + ForEach(coordinator.availablePresets(), id: \.id) { preset in + Button { + coordinator.setPreset(preset, for: activityType) + } label: { + HStack { + Text("\(preset.symbol) \(preset.name)") + .foregroundColor(.primary) + Spacer() + if coordinator.settings.presetId(for: activityType) == preset.id { + Image(systemName: "checkmark.circle.fill") + .foregroundColor(.blue) + } else { + Image(systemName: "circle") + .foregroundColor(.secondary) + } + } + } + .buttonStyle(PlainButtonStyle()) + } + } + } + + // MARK: - Detection Settings Section + + private var detectionSettingsSection: some View { + Section("Detection Settings") { + VStack(alignment: .leading, spacing: 8) { + HStack { + Text("Continuous Activity Time") + .font(.headline) + Spacer() + Text(formatContinuousActivityTime(coordinator.settings.continuousActivityTime)) + .foregroundColor(.secondary) + } + + Text("After enough steps are detected, how long sustained activity must continue before the preset activates. Acts as a confirmation that you are truly active and not just briefly moving.") + .font(.caption) + .foregroundColor(.secondary) + + Slider( + value: Binding( + get: { continuousActivityTimeSliderValue(from: coordinator.settings.continuousActivityTime) }, + set: { sliderValue in + coordinator.updateSettings { $0.continuousActivityTime = continuousActivityTimeFromSlider(sliderValue) } + } + ), + in: 0 ... 12, + step: 1 + ) + } + .padding(.vertical, 4) + + VStack(alignment: .leading, spacing: 8) { + HStack { + Text("Stop Delay") + .font(.headline) + Spacer() + Text(formatContinuousActivityTime(coordinator.settings.stopInterval)) + .foregroundColor(.secondary) + } + + Text("How long to wait after motion stops before deactivating preset.") + .font(.caption) + .foregroundColor(.secondary) + + Slider( + value: Binding( + get: { continuousActivityTimeSliderValue(from: coordinator.settings.stopInterval) }, + set: { sliderValue in + coordinator.updateSettings { $0.stopInterval = continuousActivityTimeFromSlider(sliderValue) } + } + ), + in: 0 ... 12, + step: 1 + ) + } + .padding(.vertical, 4) + + // High Confidence toggle hidden — CoreMotion's classifier is too + // slow/unreliable to gate confirmation. Step-based checks (rate + + // recency) are sufficient. Backend code remains for future use. + // Toggle(isOn: Binding( + // get: { coordinator.settings.requireHighConfidence }, + // set: { value in + // coordinator.updateSettings { $0.requireHighConfidence = value } + // } + // )) { + // VStack(alignment: .leading) { + // Text("Require High Confidence") + // .font(.headline) + // Text("Only activate preset when motion is detected with high confidence. More strict but may miss some activity.") + // .font(.caption) + // .foregroundColor(.secondary) + // } + // } + } + } + + // MARK: - Activity Log Section + + @ViewBuilder + private var activityLogSection: some View { + if !coordinator.settings.recentActivityLog.isEmpty { + Section("Recent Activity (last 20 events)") { + ForEach(coordinator.settings.recentActivityLog) { logEntry in + activityLogRow(for: logEntry) + } + + Button(role: .destructive) { + coordinator.clearActivityLog() + } label: { + HStack { + Spacer() + Text("Clear Logs") + Spacer() + } + } + } + } + } + + // MARK: - Debug Logs Section + + private var debugLogsSection: some View { + Section("Debug Logs") { + Toggle(isOn: Binding( + get: { coordinator.settings.debugLoggingEnabled }, + set: { value in + coordinator.updateSettings { $0.debugLoggingEnabled = value } + } + )) { + VStack(alignment: .leading) { + Text("Enable Debug Logging") + .font(.headline) + Text("Records detailed activity detection events for troubleshooting.") + .font(.caption) + .foregroundColor(.secondary) + } + } + + if coordinator.settings.debugLoggingEnabled { + Button { + let logs = AutoPresets_Logger.shared.getLogContents() + UIPasteboard.general.string = logs + debugLogsCopied = true + DispatchQueue.main.asyncAfter(deadline: .now() + 2) { + debugLogsCopied = false + } + } label: { + HStack { + Image(systemName: "doc.on.doc") + Text(debugLogsCopied ? "Copied!" : "Copy Debug Logs to Clipboard") + Spacer() + } + } + + Button { + showingDebugLogs = true + } label: { + HStack { + Image(systemName: "doc.text") + Text("View Debug Logs") + Spacer() + Image(systemName: "chevron.right") + .foregroundColor(.secondary) + } + } + + Button(role: debugLogsCleared ? .cancel : .destructive) { + AutoPresets_Logger.shared.clearLogs() + debugLogsCleared = true + DispatchQueue.main.asyncAfter(deadline: .now() + 2) { + debugLogsCleared = false + } + } label: { + HStack { + Spacer() + Text(debugLogsCleared ? "Cleared!" : "Clear Debug Logs") + Spacer() + } + } + } + } + } + + private func activityLogRow(for logEntry: AutoPresetsLogEntry) -> some View { + HStack { + Image(systemName: logEntry.event.iconName) + .foregroundColor(colorForEvent(logEntry.event)) + .frame(width: 24) + + VStack(alignment: .leading) { + HStack { + Text(logEntry.event.displayName) + .font(.subheadline) + .fontWeight(.medium) + if let activityType = logEntry.activityType { + Text("(\(activityType.displayName))") + .font(.caption) + .foregroundColor(.secondary) + } + } + if let presetName = logEntry.presetName { + Text(presetName) + .font(.caption) + .foregroundColor(.secondary) + } + + if logEntry.event == .presetDeactivated, + let activationEntry = findMatchingActivationEntry(for: logEntry) + { + let duration = logEntry.date.timeIntervalSince(activationEntry.date) + Text("Duration: \(formatDuration(duration))") + .font(.caption) + .foregroundColor(.blue) + } + } + + Spacer() + + VStack(alignment: .trailing) { + Text(Self.relativeDateFormatter.string(for: logEntry.date) ?? "") + .font(.caption) + .foregroundColor(.secondary) + Text(Self.timeFormatter.string(from: logEntry.date)) + .font(.caption2) + .foregroundColor(.secondary) + } + } + } + + // MARK: - Helper Methods + + private func activityToggleBinding(for activityType: AutoPresetsActivityType) -> Binding { + Binding( + get: { coordinator.settings.supportedActivityTypes.contains(activityType) }, + set: { enabled in + if enabled { + guard !coordinator.availablePresets().isEmpty else { + showErrorAlert("No presets available to assign to activity types. Please create presets first.") + return + } + } + coordinator.updateSettings { settings in + if enabled { + settings.supportedActivityTypes.insert(activityType) + } else { + settings.supportedActivityTypes.remove(activityType) + } + } + } + ) + } + + private func toggleActivityType(_ activityType: AutoPresetsActivityType) { + let currentlyEnabled = coordinator.settings.supportedActivityTypes.contains(activityType) + + if !currentlyEnabled { + guard !coordinator.availablePresets().isEmpty else { + showErrorAlert("No presets available to assign to activity types. Please create presets first.") + return + } + } + + coordinator.updateSettings { settings in + if currentlyEnabled { + settings.supportedActivityTypes.remove(activityType) + } else { + settings.supportedActivityTypes.insert(activityType) + } + } + } + + private func colorForEvent(_ event: AutoPresetsLogEvent) -> Color { + switch event { + case .presetActivated: return .blue + case .presetDeactivated: return .blue + case .featureEnabled: return .green + case .featureDisabled: return .orange + } + } + + private func findMatchingActivationEntry(for deactivationEntry: AutoPresetsLogEntry) -> AutoPresetsLogEntry? { + guard deactivationEntry.event == .presetDeactivated else { return nil } + + return coordinator.settings.recentActivityLog.first { entry in + entry.event == .presetActivated && + entry.activityType == deactivationEntry.activityType && + entry.presetName == deactivationEntry.presetName && + entry.date < deactivationEntry.date + } + } + + private func formatDuration(_ duration: TimeInterval) -> String { + let formatter = DateComponentsFormatter() + formatter.allowedUnits = [.hour, .minute, .second] + formatter.unitsStyle = .abbreviated + formatter.maximumUnitCount = 2 + return formatter.string(from: duration) ?? "\(Int(duration))s" + } + + private func showErrorAlert(_ message: String) { + errorMessage = message + showingErrorAlert = true + } + + // MARK: - Continuous Activity Time Slider + + private static let continuousActivityTimeValues: [TimeInterval] = [10, 20, 30, 60, 120, 180, 240, 300, 360, 420, 480, 540, 600] + + private func continuousActivityTimeSliderValue(from interval: TimeInterval) -> Double { + if let index = Self.continuousActivityTimeValues.firstIndex(where: { $0 >= interval }) { + return Double(index) + } + return 12 + } + + private func continuousActivityTimeFromSlider(_ sliderValue: Double) -> TimeInterval { + let index = Int(sliderValue.rounded()) + guard index >= 0 && index < Self.continuousActivityTimeValues.count else { + return 30 + } + return Self.continuousActivityTimeValues[index] + } + + private func formatContinuousActivityTime(_ interval: TimeInterval) -> String { + if interval < 60 { + return "\(Int(interval)) sec" + } else { + let minutes = Int(interval / 60) + return "\(minutes) min" + } + } + + // MARK: - Formatters + + private static var relativeDateFormatter: RelativeDateTimeFormatter = { + let formatter = RelativeDateTimeFormatter() + formatter.dateTimeStyle = .named + return formatter + }() + + private static var timeFormatter: DateFormatter = { + let formatter = DateFormatter() + formatter.timeStyle = .short + return formatter + }() +} + +// MARK: - Debug Logs View + +struct AutoPresets_DebugLogsView: View { + @Binding var isPresented: Bool + @State private var logContents: String = "" + + var body: some View { + NavigationView { + ScrollView { + Text(logContents) + .font(.system(.caption, design: .monospaced)) + .padding() + .frame(maxWidth: .infinity, alignment: .leading) + } + .navigationTitle("Debug Logs") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .confirmationAction) { + Button("Done") { + isPresented = false + } + } + } + } + .onAppear { + logContents = AutoPresets_Logger.shared.getLogContents() + } + } +} + +// MARK: - Icon View + +struct AutoPresets_IconView: View { + @ObservedObject private var coordinator = AutoPresets_Coordinator.shared + @State private var isAnimating = false + + var body: some View { + Image(systemName: "figure.walk") + .resizable() + .scaledToFit() + .frame(width: 36, height: 36) + .foregroundColor(coordinator.isEnabled ? Color(red: 76/255, green: 175/255, blue: 80/255) : .secondary) + .scaleEffect(coordinator.isEnabled && isAnimating ? 1.3 : 1.0) + .animation( + coordinator.isEnabled ? .easeInOut(duration: 0.4).repeatForever(autoreverses: true) : .default, + value: isAnimating + ) + .onAppear { + if coordinator.isEnabled { + isAnimating = true + } + } + .onChange(of: coordinator.isEnabled) { newValue in + isAnimating = newValue + } + } +} + +// MARK: - Preview + +#if DEBUG +struct AutoPresets_SettingsView_Previews: PreviewProvider { + static var previews: some View { + NavigationView { + AutoPresets_SettingsView() + } + } +} +#endif diff --git a/Loop/Views/SettingsView.swift b/Loop/Views/SettingsView.swift index aa0da33134..42fb2743c1 100644 --- a/Loop/Views/SettingsView.swift +++ b/Loop/Views/SettingsView.swift @@ -298,6 +298,18 @@ extension SettingsView { descriptiveText: NSLocalizedString("Diabetes Treatment", comment: "Descriptive text for Therapy Settings")) } + if AutoPresets_FeatureFlags.isEnabled { + NavigationLink(destination: AutoPresets_SettingsView()) { + LargeButton( + action: {}, + includeArrow: false, + imageView: AutoPresets_IconView(), + label: NSLocalizedString("AutoPresets", comment: "Title text for button to AutoPresets Settings"), + descriptiveText: NSLocalizedString("Automate your presets during motion", comment: "Descriptive text for Auto-Apply Presets") + ) + } + } + ForEach(pluginMenuItems.filter {$0.section == .configuration}) { item in item.view } From ccd392946636c7e4262d5d663b1725fe52f9df8c Mon Sep 17 00:00:00 2001 From: Taylor Date: Wed, 18 Feb 2026 11:31:43 -0800 Subject: [PATCH 2/4] Always show AutoPresets in Settings, default to disabled MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Remove feature flag guard from SettingsView so AutoPresets row always appears. The feature itself still defaults to off — user enables it from within AutoPresets settings. --- Loop/Views/SettingsView.swift | 18 ++++++++---------- 1 file changed, 8 insertions(+), 10 deletions(-) diff --git a/Loop/Views/SettingsView.swift b/Loop/Views/SettingsView.swift index 42fb2743c1..db3df6ccba 100644 --- a/Loop/Views/SettingsView.swift +++ b/Loop/Views/SettingsView.swift @@ -298,16 +298,14 @@ extension SettingsView { descriptiveText: NSLocalizedString("Diabetes Treatment", comment: "Descriptive text for Therapy Settings")) } - if AutoPresets_FeatureFlags.isEnabled { - NavigationLink(destination: AutoPresets_SettingsView()) { - LargeButton( - action: {}, - includeArrow: false, - imageView: AutoPresets_IconView(), - label: NSLocalizedString("AutoPresets", comment: "Title text for button to AutoPresets Settings"), - descriptiveText: NSLocalizedString("Automate your presets during motion", comment: "Descriptive text for Auto-Apply Presets") - ) - } + NavigationLink(destination: AutoPresets_SettingsView()) { + LargeButton( + action: {}, + includeArrow: false, + imageView: AutoPresets_IconView(), + label: NSLocalizedString("AutoPresets", comment: "Title text for button to AutoPresets Settings"), + descriptiveText: NSLocalizedString("Automate your presets during motion", comment: "Descriptive text for Auto-Apply Presets") + ) } ForEach(pluginMenuItems.filter {$0.section == .configuration}) { item in From 04804fa7a14f3cbfd54a2cbaa5c8a784a90d67ca Mon Sep 17 00:00:00 2001 From: Taylor Date: Tue, 3 Mar 2026 09:09:10 -0800 Subject: [PATCH 3/4] Let pedometer evidence confirm walking without classifier gate Pedometer step count is ground truth for walking. CoreMotion's activity classifier updates too slowly and vetoes valid walks. Now two paths to confirmation: strong pedometer evidence alone, or classifier + low step bar. --- .../AutoPresets_ActivityDetectionManager.swift | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/Loop/Managers/AutoPresets/AutoPresets_ActivityDetectionManager.swift b/Loop/Managers/AutoPresets/AutoPresets_ActivityDetectionManager.swift index e43a502700..dc0d05adcd 100644 --- a/Loop/Managers/AutoPresets/AutoPresets_ActivityDetectionManager.swift +++ b/Loop/Managers/AutoPresets/AutoPresets_ActivityDetectionManager.swift @@ -407,7 +407,13 @@ class AutoPresets_ActivityDetectionManager { classifierConfirmed = true } - if additionalSteps >= minAdditionalSteps && stepIsRecent && classifierConfirmed { + // Two paths to confirmation: + // 1. Strong pedometer evidence: steps are recent AND enough additional steps accumulated + // 2. Classifier shortcut: CoreMotion confirmed the activity at high confidence (even with fewer steps) + let pedometerSufficient = stepIsRecent && additionalSteps >= minAdditionalSteps + let classifierBoost = stepIsRecent && classifierConfirmed && additionalSteps >= 15 + + if pedometerSufficient || classifierBoost { let activityType = classifierType ?? activity os_log( @@ -432,8 +438,6 @@ class AutoPresets_ActivityDetectionManager { let reason: String if !stepIsRecent { reason = "user stopped walking before timer fired" - } else if !classifierConfirmed { - reason = "CoreMotion classifier did not confirm activity at high confidence" } else { reason = "only \(additionalSteps) additional steps (need >= \(minAdditionalSteps))" } From e5e107d3fcd881ce7596c04afdf0eb52ad891f80 Mon Sep 17 00:00:00 2001 From: Taylor Date: Tue, 3 Mar 2026 10:46:30 -0800 Subject: [PATCH 4/4] Fix negative step count from stale pedometer data after reset When the pedometer restarts, a stale batched update from the previous session can inflate stepsAtThreshold. Subsequent updates from the new session are lower, producing negative additionalSteps. Now detects the restart and treats all current steps as additional. --- .../AutoPresets/AutoPresets_ActivityDetectionManager.swift | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/Loop/Managers/AutoPresets/AutoPresets_ActivityDetectionManager.swift b/Loop/Managers/AutoPresets/AutoPresets_ActivityDetectionManager.swift index dc0d05adcd..ae82d2d19a 100644 --- a/Loop/Managers/AutoPresets/AutoPresets_ActivityDetectionManager.swift +++ b/Loop/Managers/AutoPresets/AutoPresets_ActivityDetectionManager.swift @@ -375,7 +375,12 @@ class AutoPresets_ActivityDetectionManager { return (self._totalSteps, self._stepThresholdReachedTime, self._lastStepChangeTime, self._detectedActivityType, self._lastClassifierTime) } - let additionalSteps = currentSteps - stepsAtThreshold + // If currentSteps < stepsAtThreshold, the pedometer restarted mid-timer + // (stale batch from old session inflated stepsAtThreshold). In that case, + // all current steps are from the new session and count as additional. + let additionalSteps = currentSteps >= stepsAtThreshold + ? currentSteps - stepsAtThreshold + : currentSteps let minAdditionalSteps = max(15, Int(elapsed / 60.0 * 30.0))