From fbfbaa8f178121c7768b3e6984edfd0a5edab48f Mon Sep 17 00:00:00 2001 From: Taylor Date: Sat, 7 Feb 2026 15:27:40 -0800 Subject: [PATCH] Add AutoPresets: auto-activate insulin presets on walking/running activity CoreMotion-based activity detection that automatically applies user-selected override presets when walking or running is detected. 7 new files, 2 modified. Co-Authored-By: Claude Opus 4.6 --- Loop.xcodeproj/project.pbxproj | 28 + Loop/Managers/ActivityDetectionManager.swift | 512 ++++++++++++++++++ Loop/Managers/AutoPresetsCoordinator.swift | 314 +++++++++++ Loop/Managers/AutoPresetsDelegate.swift | 26 + Loop/Managers/AutoPresetsLogger.swift | 160 ++++++ Loop/Managers/AutoPresetsStorage.swift | 193 +++++++ Loop/Managers/LoopDataManager.swift | 45 +- Loop/Models/AutoPresetsModels.swift | 225 ++++++++ Loop/Views/AutoPresetsSettingsView.swift | 532 +++++++++++++++++++ Loop/Views/SettingsView.swift | 10 + 10 files changed, 2043 insertions(+), 2 deletions(-) create mode 100644 Loop/Managers/ActivityDetectionManager.swift create mode 100644 Loop/Managers/AutoPresetsCoordinator.swift create mode 100644 Loop/Managers/AutoPresetsDelegate.swift create mode 100644 Loop/Managers/AutoPresetsLogger.swift create mode 100644 Loop/Managers/AutoPresetsStorage.swift create mode 100644 Loop/Models/AutoPresetsModels.swift create mode 100644 Loop/Views/AutoPresetsSettingsView.swift diff --git a/Loop.xcodeproj/project.pbxproj b/Loop.xcodeproj/project.pbxproj index 4767ba3142..592e700602 100644 --- a/Loop.xcodeproj/project.pbxproj +++ b/Loop.xcodeproj/project.pbxproj @@ -12,6 +12,13 @@ 1419606A28D955BC00BA86E0 /* MockKitUI.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = C101947127DD473C004E7EB8 /* MockKitUI.framework */; }; 142CB7592A60BF2E0075748A /* EditMode.swift in Sources */ = {isa = PBXBuildFile; fileRef = 142CB7582A60BF2E0075748A /* EditMode.swift */; }; 142CB75B2A60BFC30075748A /* FavoriteFoodsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 142CB75A2A60BFC30075748A /* FavoriteFoodsView.swift */; }; + AUTOPRESET00000001 /* AutoPresetsDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = AUTOPRESET00000011 /* AutoPresetsDelegate.swift */; }; + AUTOPRESET00000002 /* AutoPresetsModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = AUTOPRESET00000012 /* AutoPresetsModels.swift */; }; + AUTOPRESET00000003 /* AutoPresetsStorage.swift in Sources */ = {isa = PBXBuildFile; fileRef = AUTOPRESET00000013 /* AutoPresetsStorage.swift */; }; + AUTOPRESET00000004 /* ActivityDetectionManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = AUTOPRESET00000014 /* ActivityDetectionManager.swift */; }; + AUTOPRESET00000005 /* AutoPresetsCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = AUTOPRESET00000015 /* AutoPresetsCoordinator.swift */; }; + AUTOPRESET00000006 /* AutoPresetsSettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = AUTOPRESET00000016 /* AutoPresetsSettingsView.swift */; }; + AUTOPRESET00000007 /* AutoPresetsLogger.swift in Sources */ = {isa = PBXBuildFile; fileRef = AUTOPRESET00000017 /* AutoPresetsLogger.swift */; }; 1452F4A92A851C9400F8B9E4 /* AddEditFavoriteFoodViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1452F4A82A851C9400F8B9E4 /* AddEditFavoriteFoodViewModel.swift */; }; 1452F4AB2A851EDF00F8B9E4 /* AddEditFavoriteFoodView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1452F4AA2A851EDF00F8B9E4 /* AddEditFavoriteFoodView.swift */; }; 1452F4AD2A851F8800F8B9E4 /* HowAbsorptionTimeWorksView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1452F4AC2A851F8800F8B9E4 /* HowAbsorptionTimeWorksView.swift */; }; @@ -760,6 +767,13 @@ /* Begin PBXFileReference section */ 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 = ""; }; + AUTOPRESET00000011 /* AutoPresetsDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AutoPresetsDelegate.swift; sourceTree = ""; }; + AUTOPRESET00000012 /* AutoPresetsModels.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AutoPresetsModels.swift; sourceTree = ""; }; + AUTOPRESET00000013 /* AutoPresetsStorage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AutoPresetsStorage.swift; sourceTree = ""; }; + AUTOPRESET00000014 /* ActivityDetectionManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ActivityDetectionManager.swift; sourceTree = ""; }; + AUTOPRESET00000015 /* AutoPresetsCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AutoPresetsCoordinator.swift; sourceTree = ""; }; + AUTOPRESET00000016 /* AutoPresetsSettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AutoPresetsSettingsView.swift; sourceTree = ""; }; + AUTOPRESET00000017 /* AutoPresetsLogger.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AutoPresetsLogger.swift; sourceTree = ""; }; 1452F4A82A851C9400F8B9E4 /* AddEditFavoriteFoodViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AddEditFavoriteFoodViewModel.swift; sourceTree = ""; }; 1452F4AA2A851EDF00F8B9E4 /* AddEditFavoriteFoodView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AddEditFavoriteFoodView.swift; sourceTree = ""; }; 1452F4AC2A851F8800F8B9E4 /* HowAbsorptionTimeWorksView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HowAbsorptionTimeWorksView.swift; sourceTree = ""; }; @@ -1658,6 +1672,7 @@ children = ( DDC389F52A2B61750066E2E8 /* ApplicationFactorStrategy.swift */, B4E2022F2661063E009421B5 /* AutomaticDosingStatus.swift */, + AUTOPRESET00000012 /* AutoPresetsModels.swift */, A9FB75F0252BE320004C7D3F /* BolusDosingDecision.swift */, DDC389F92A2B62470066E2E8 /* ConstantApplicationFactorStrategy.swift */, C1EF747128D6A44A00C8C083 /* CrashRecoveryManager.swift */, @@ -1972,6 +1987,7 @@ C191D2A025B3ACAA00C26C0B /* DosingStrategySelectionView.swift */, 149A28E32A8A63A700052EDF /* FavoriteFoodDetailView.swift */, 142CB75A2A60BFC30075748A /* FavoriteFoodsView.swift */, + AUTOPRESET00000016 /* AutoPresetsSettingsView.swift */, 43D381611EBD9759007F8C8F /* HeaderValuesTableViewCell.swift */, 1452F4AC2A851F8800F8B9E4 /* HowAbsorptionTimeWorksView.swift */, B43CF07D29434EC4008A520B /* HowMuteAlertWorkView.swift */, @@ -2001,8 +2017,13 @@ 43F5C2E41B93C5D4003EB13D /* Managers */ = { isa = PBXGroup; children = ( + AUTOPRESET00000014 /* ActivityDetectionManager.swift */, B42D124228D371C400E43D22 /* AlertMuter.swift */, 1D6B1B6626866D89009AC446 /* AlertPermissionsChecker.swift */, + AUTOPRESET00000015 /* AutoPresetsCoordinator.swift */, + AUTOPRESET00000011 /* AutoPresetsDelegate.swift */, + AUTOPRESET00000017 /* AutoPresetsLogger.swift */, + AUTOPRESET00000013 /* AutoPresetsStorage.swift */, 439897361CD2F80600223065 /* AnalyticsServicesManager.swift */, B4F3D25024AF890C0095CE44 /* BluetoothStateManager.swift */, 439BED291E76093C00B0AED5 /* CGMManager.swift */, @@ -3425,6 +3446,13 @@ 1DA649A9244126DA00F61E75 /* InAppModalAlertScheduler.swift in Sources */, 43B260491ED248FB008CAA77 /* CarbEntryTableViewCell.swift in Sources */, 142CB75B2A60BFC30075748A /* FavoriteFoodsView.swift in Sources */, + AUTOPRESET00000001 /* AutoPresetsDelegate.swift in Sources */, + AUTOPRESET00000002 /* AutoPresetsModels.swift in Sources */, + AUTOPRESET00000003 /* AutoPresetsStorage.swift in Sources */, + AUTOPRESET00000004 /* ActivityDetectionManager.swift in Sources */, + AUTOPRESET00000005 /* AutoPresetsCoordinator.swift in Sources */, + AUTOPRESET00000006 /* AutoPresetsSettingsView.swift in Sources */, + AUTOPRESET00000007 /* AutoPresetsLogger.swift in Sources */, A9D5C5B625DC6C6A00534873 /* LoopAppManager.swift in Sources */, 4302F4E11D4E9C8900F0FCAF /* TextFieldTableViewController.swift in Sources */, 1452F4AB2A851EDF00F8B9E4 /* AddEditFavoriteFoodView.swift in Sources */, diff --git a/Loop/Managers/ActivityDetectionManager.swift b/Loop/Managers/ActivityDetectionManager.swift new file mode 100644 index 0000000000..4a7d29a197 --- /dev/null +++ b/Loop/Managers/ActivityDetectionManager.swift @@ -0,0 +1,512 @@ +// +// ActivityDetectionManager.swift +// Loop +// +// Created for Loop AutoPresets Feature +// + +import CoreMotion +import Foundation +import os.log + +// MARK: - Internal Delegate Protocol + +/// Internal protocol for activity detection callbacks +protocol ActivityDetectionDelegate: AnyObject { + func activityDetectionDidConfirm(_ activity: AutoPresetActivityType) + func activityDetectionDidStop(_ activity: AutoPresetActivityType) + func activityDetectionDidEncounterError(_ error: AutoPresetDetectionError) +} + +// 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 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 = AutoPresetsLogger.shared + private let stateQueue = DispatchQueue(label: "com.loopkit.AutoPresets.ActivityDetection.state", qos: .utility) + + weak var delegate: ActivityDetectionDelegate? + + private let pedometer = CMPedometer() + private let motionActivityManager = CMMotionActivityManager() + + // Thread-safe state variables + private var _isMonitoring = false + private var _currentActivity: AutoPresetActivityType? + private var _detectedActivityType: AutoPresetActivityType? + private var _stepThresholdReachedTime: Date? + private var _pedometerStartTime: Date? + private var _totalSteps: Int = 0 + + private var isMonitoring: Bool { + get { stateQueue.sync { _isMonitoring } } + set { stateQueue.sync { _isMonitoring = newValue } } + } + + private var currentActivity: AutoPresetActivityType? { + 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: AutoPresetActivityType? { + currentActivity + } + + var isActivityDetected: Bool { + currentActivity != nil + } + + // MARK: - Initialization + + init() { + os_log("ActivityDetectionManager initialized", log: log, type: .debug) + } + + deinit { + os_log("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 + } + + 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 + } + + 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) = stateQueue.sync { () -> (Bool, Bool) in + _totalSteps = totalSteps + + // Already confirmed — nothing to do + guard _currentActivity == nil else { + return (false, true) + } + + // Check if we just crossed the step threshold + if totalSteps >= stepThreshold && _stepThresholdReachedTime == nil { + _stepThresholdReachedTime = Date() + return (true, false) + } + + return (false, false) + } + + if alreadyConfirmed { + // Steps still coming - restart the stop timer + 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: AutoPresetActivityType? + 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 + } + } 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: AutoPresetActivityType) { + 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) = self.stateQueue.sync { () -> (Int, Date?) in + return (self._totalSteps, self._stepThresholdReachedTime) + } + + let additionalSteps = currentSteps - stepsAtThreshold + + if additionalSteps > 5 { + // Steps are still accumulating — confirm the activity + let activityType = self.stateQueue.sync { self._detectedActivityType } ?? 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) + + // Start the stop timer - will fire if no more steps come in + self.startActivityStopTimer() + } else { + // Not enough additional steps — user may have stopped + os_log( + "%{public}@ confirmation failed - only %{public}d additional steps since threshold (need > 5)", + log: self.log, + type: .debug, + activity.displayName, + additionalSteps + ) + + self.stateQueue.sync { + self._stepThresholdReachedTime = nil + self._continuousActivityTimer = nil + } + + // Reset pedometer to start fresh + 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 stepsAtStopStart = stateQueue.sync { _totalSteps } + + 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 + } + + // Check if steps increased during the stop interval + let currentSteps = self.stateQueue.sync { self._totalSteps } + let additionalSteps = currentSteps - stepsAtStopStart + + if additionalSteps > 10 { + // User resumed walking — cancel the stop + os_log( + "Stop cancelled - %{public}d steps detected during stop interval", + log: self.log, + type: .info, + additionalSteps + ) + self.fileLog.log("Stop cancelled - \(additionalSteps) steps during stop interval, continuing activity") + self.stateQueue.sync { + self._activityStopTimer = nil + } + } else { + // User has stopped — deactivate + let activityToStop = self.stateQueue.sync { () -> AutoPresetActivityType? 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 (%{public}d steps)", + log: self.log, + type: .info, + activity.displayName, + self.activityStopInterval, + additionalSteps + ) + self.fileLog.log("DEACTIVATED \(activity.displayName) after \(self.activityStopInterval)s stop interval (\(additionalSteps) steps)") + } + + // Reset pedometer for next detection cycle + 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 + } + + // Restart pedometer for next detection cycle + if isMonitoring { + startPedometerUpdates() + } + } +} diff --git a/Loop/Managers/AutoPresetsCoordinator.swift b/Loop/Managers/AutoPresetsCoordinator.swift new file mode 100644 index 0000000000..d0dbdc5354 --- /dev/null +++ b/Loop/Managers/AutoPresetsCoordinator.swift @@ -0,0 +1,314 @@ +// +// AutoPresetsCoordinator.swift +// Loop +// +// Created for Loop AutoPresets Feature +// + +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 AutoPresetsCoordinator: ObservableObject { + + // MARK: - Singleton + + public static let shared = AutoPresetsCoordinator() + + // MARK: - Published Properties + + @Published public private(set) var isMonitoring: Bool = false + @Published public private(set) var currentDetectedActivity: AutoPresetActivityType? + @Published public private(set) var lastError: AutoPresetDetectionError? + + // MARK: - Private Properties + + private let log = OSLog(subsystem: "com.loopkit.Loop.AutoPresets", category: "Coordinator") + private let storage = AutoPresetsStorage() + private let activityDetectionManager = ActivityDetectionManager() + + // Debounce/guard properties to prevent rapid restarts + private var isUpdatingSettings = false + private var pendingRestart: DispatchWorkItem? + + public weak var delegate: AutoPresetsDelegate? { + 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("AutoPresetsCoordinator 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: AutoPresetActivityType) -> 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: AutoPresetActivityType) { + 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: AutoPresetLogEvent, activity: AutoPresetActivityType? = nil, presetName: String? = nil) { + storage.addLogEntry(event: event, activityType: activity, presetName: presetName) + } + + private func activatePreset(for activity: AutoPresetActivityType) { + 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: AutoPresetActivityType) { + 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: - ActivityDetectionDelegate + +extension AutoPresetsCoordinator: ActivityDetectionDelegate { + + func activityDetectionDidConfirm(_ activity: AutoPresetActivityType) { + DispatchQueue.main.async { [weak self] in + guard let self = self else { return } + + self.currentDetectedActivity = activity + self.activatePreset(for: activity) + } + } + + func activityDetectionDidStop(_ activity: AutoPresetActivityType) { + DispatchQueue.main.async { [weak self] in + guard let self = self else { return } + + self.currentDetectedActivity = nil + self.deactivatePreset(for: activity) + } + } + + func activityDetectionDidEncounterError(_ error: AutoPresetDetectionError) { + 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 + ) + + // Note: We intentionally do NOT disable the feature on errors + // The user's preference should be preserved + } + } +} diff --git a/Loop/Managers/AutoPresetsDelegate.swift b/Loop/Managers/AutoPresetsDelegate.swift new file mode 100644 index 0000000000..5dd50e6fb3 --- /dev/null +++ b/Loop/Managers/AutoPresetsDelegate.swift @@ -0,0 +1,26 @@ +// +// AutoPresetsDelegate.swift +// Loop +// +// Created for Loop AutoPresets Feature +// + +import Foundation +import LoopKit + +/// Protocol that Loop implements to receive commands from AutoPresets +public protocol AutoPresetsDelegate: AnyObject { + /// Called when AutoPresets wants to activate a preset + func autoPresets(_ coordinator: AutoPresetsCoordinator, + shouldActivatePreset preset: TemporaryScheduleOverridePreset) + + /// Called when AutoPresets wants to deactivate the current preset + func autoPresets(_ coordinator: AutoPresetsCoordinator, + shouldDeactivatePreset preset: TemporaryScheduleOverridePreset) + + /// Returns currently available override presets from Loop + func autoPresetsAvailablePresets(_ coordinator: AutoPresetsCoordinator) -> [TemporaryScheduleOverridePreset] + + /// Returns the currently active override, if any + func autoPresetsCurrentOverride(_ coordinator: AutoPresetsCoordinator) -> TemporaryScheduleOverride? +} diff --git a/Loop/Managers/AutoPresetsLogger.swift b/Loop/Managers/AutoPresetsLogger.swift new file mode 100644 index 0000000000..c3a418df8e --- /dev/null +++ b/Loop/Managers/AutoPresetsLogger.swift @@ -0,0 +1,160 @@ +// +// AutoPresetsLogger.swift +// Loop +// +// Created for Loop AutoPresets Feature +// + +import Foundation + +/// Simple file-based logger for AutoPresets debugging +/// Logs are written to Documents/AutoPresetsLog.txt +public class AutoPresetsLogger { + + // MARK: - Singleton + + public static let shared = AutoPresetsLogger() + + // 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 { + AutoPresetsStorage().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/AutoPresetsStorage.swift b/Loop/Managers/AutoPresetsStorage.swift new file mode 100644 index 0000000000..89592079db --- /dev/null +++ b/Loop/Managers/AutoPresetsStorage.swift @@ -0,0 +1,193 @@ +// +// AutoPresetsStorage.swift +// Loop +// +// Created for Loop AutoPresets Feature +// + +import Foundation +import os.log + +/// Isolated persistence for AutoPresets using its own UserDefaults suite +public class AutoPresetsStorage { + + 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: AutoPresetLogEntry) { + 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: AutoPresetLogEvent, + activityType: AutoPresetActivityType? = nil, + presetName: String? = nil + ) { + let entry = AutoPresetLogEntry( + 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: [AutoPresetLogEntry] = [] + if let logData = legacyDefaults.data(forKey: "com.loopkit.Loop.recentWalkingActivityLog") { + // Try to decode legacy log format and convert + // Note: Legacy format used different types, so we need to handle conversion + 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 AutoPresetLogEntry( + id: UUID(), + date: legacy.date, + event: event, + activityType: legacy.activityType.flatMap { AutoPresetActivityType(rawValue: $0) }, + presetName: legacy.presetName + ) + } + } + } + + // Convert supported types + let supportedTypes = Set(supportedTypesRaw.compactMap { AutoPresetActivityType(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 + + 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) -> AutoPresetLogEvent? { + 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..50f9e664be 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 + AutoPresetsCoordinator.shared.delegate = self + if #available(iOS 16.2, *) { self.liveActivityManager = LiveActivityManager( glucoseStore: self.glucoseStore, @@ -2612,5 +2615,43 @@ extension LoopDataManager: ServicesManagerDelegate { } } } - + +} + +// MARK: - AutoPresetsDelegate + +extension LoopDataManager: AutoPresetsDelegate { + + func autoPresets(_ coordinator: AutoPresetsCoordinator, + shouldActivatePreset preset: TemporaryScheduleOverridePreset) { + logger.default("AutoPresets activating preset: %{public}@", preset.name) + + mutateSettings { settings in + settings.scheduleOverride = preset.createOverride(enactTrigger: .local) + } + } + + func autoPresets(_ coordinator: AutoPresetsCoordinator, + 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: AutoPresetsCoordinator) -> [TemporaryScheduleOverridePreset] { + settings.overridePresets + } + + func autoPresetsCurrentOverride(_ coordinator: AutoPresetsCoordinator) -> TemporaryScheduleOverride? { + settings.scheduleOverride + } } diff --git a/Loop/Models/AutoPresetsModels.swift b/Loop/Models/AutoPresetsModels.swift new file mode 100644 index 0000000000..f2a7bf980b --- /dev/null +++ b/Loop/Models/AutoPresetsModels.swift @@ -0,0 +1,225 @@ +// +// AutoPresetsModels.swift +// Loop +// +// Created for Loop AutoPresets Feature +// + +import Foundation + +// MARK: - Activity Types + +/// Supported activity types for auto-preset activation +public enum AutoPresetActivityType: 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 AutoPresetLogEvent: 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 AutoPresetLogEntry: Codable, Identifiable, Equatable { + public let id: UUID + public let date: Date + public let event: AutoPresetLogEvent + public let activityType: AutoPresetActivityType? + public let presetName: String? + + public init( + id: UUID = UUID(), + date: Date = Date(), + event: AutoPresetLogEvent, + activityType: AutoPresetActivityType? = 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: [AutoPresetLogEntry] + + public init( + isEnabled: Bool = false, + supportedActivityTypes: Set = [.walking], + activityPresets: [String: String] = [:], + stopInterval: TimeInterval = 300, + continuousActivityTime: TimeInterval = 30, + requireHighConfidence: Bool = false, + debugLoggingEnabled: Bool = false, + recentActivityLog: [AutoPresetLogEntry] = [] + ) { + 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([AutoPresetLogEntry].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: AutoPresetActivityType) -> 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: AutoPresetActivityType) { + 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 AutoPresetDetectionError: 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/Views/AutoPresetsSettingsView.swift b/Loop/Views/AutoPresetsSettingsView.swift new file mode 100644 index 0000000000..69fdba228e --- /dev/null +++ b/Loop/Views/AutoPresetsSettingsView.swift @@ -0,0 +1,532 @@ +// +// AutoPresetsSettingsView.swift +// Loop +// +// Created for Loop AutoPresets Feature +// + +import LoopKit +import SwiftUI +import UIKit + +// MARK: - Main Settings View + +struct AutoPresetsSettingsView: View { + @ObservedObject private var coordinator = AutoPresetsCoordinator.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("Auto-Apply Presets") + .navigationBarTitleDisplayMode(.inline) + .alert("Configuration Error", isPresented: $showingErrorAlert) { + Button("OK") {} + } message: { + Text(errorMessage) + } + .sheet(isPresented: $showingDebugLogs) { + DebugLogsView(isPresented: $showingDebugLogs) + } + } + + // MARK: - Enable Section + + private var enableSection: some View { + Section { + 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 Auto-Apply Presets.") + return + } + } + coordinator.isEnabled = enabled + } + )) { + VStack(alignment: .leading) { + Text("Enable Auto-Preset") + .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(AutoPresetActivityType.allCases, id: \.self) { activityType in + Section { + activityTypeRow(for: activityType) + + if coordinator.settings.supportedActivityTypes.contains(activityType) { + presetSelectionView(for: activityType) + } + } + } + } + + private func activityTypeRow(for activityType: AutoPresetActivityType) -> 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: AutoPresetActivityType) -> 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) + + 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 = AutoPresetsLogger.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) { + AutoPresetsLogger.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: AutoPresetLogEntry) -> 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: AutoPresetActivityType) -> 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: AutoPresetActivityType) { + 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: AutoPresetLogEvent) -> Color { + switch event { + case .presetActivated: return .blue + case .presetDeactivated: return .blue + case .featureEnabled: return .green + case .featureDisabled: return .orange + } + } + + private func findMatchingActivationEntry(for deactivationEntry: AutoPresetLogEntry) -> AutoPresetLogEntry? { + 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 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 = AutoPresetsLogger.shared.getLogContents() + } + } +} + +// MARK: - Icon View + +struct AutoPresetsIconView: View { + @ObservedObject private var coordinator = AutoPresetsCoordinator.shared + @State private var isAnimating = false + + var body: some View { + Image(systemName: "figure.walk") + .resizable() + .scaledToFit() + .frame(width: 36, height: 36) + .foregroundColor(coordinator.isEnabled ? .blue : .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 AutoPresetsSettingsView_Previews: PreviewProvider { + static var previews: some View { + NavigationView { + AutoPresetsSettingsView() + } + } +} +#endif diff --git a/Loop/Views/SettingsView.swift b/Loop/Views/SettingsView.swift index aa0da33134..a87c2cac5a 100644 --- a/Loop/Views/SettingsView.swift +++ b/Loop/Views/SettingsView.swift @@ -298,6 +298,16 @@ extension SettingsView { descriptiveText: NSLocalizedString("Diabetes Treatment", comment: "Descriptive text for Therapy Settings")) } + NavigationLink(destination: AutoPresetsSettingsView()) { + LargeButton( + action: {}, + includeArrow: false, + imageView: AutoPresetsIconView(), + label: NSLocalizedString("Auto-Apply Presets", comment: "Title text for button to Auto-Apply Presets 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 }