diff --git a/Loop/Extensions/UserDefaults+Loop.swift b/Loop/Extensions/UserDefaults+Loop.swift index fe57219067..58e44a9b84 100644 --- a/Loop/Extensions/UserDefaults+Loop.swift +++ b/Loop/Extensions/UserDefaults+Loop.swift @@ -19,6 +19,7 @@ extension UserDefaults { case inFlightAutomaticDose = "com.loopkit.Loop.inFlightAutomaticDose" case favoriteFoods = "com.loopkit.Loop.favoriteFoods" case automationHistory = "com.loopkit.Loop.automationHistory" + case showsActivityPresets = "com.loopkit.Loop.showsActivityPresets" } var legacyPumpManagerRawValue: PumpManager.RawValue? { @@ -130,4 +131,13 @@ extension UserDefaults { } } } + + var showsActivityPresets: Bool { + get { + object(forKey: Key.showsActivityPresets.rawValue) as? Bool ?? true + } + set { + set(newValue, forKey: Key.showsActivityPresets.rawValue) + } + } } diff --git a/Loop/Managers/TemporaryPresetsManager.swift b/Loop/Managers/TemporaryPresetsManager.swift index 3a062a3f7f..084628f8de 100644 --- a/Loop/Managers/TemporaryPresetsManager.swift +++ b/Loop/Managers/TemporaryPresetsManager.swift @@ -37,9 +37,31 @@ class TemporaryPresetsManager { private var now: Date { TestingDate.currentTestingDate() } - init(settingsProvider: SettingsProvider, alertIssuer: AlertIssuer? = nil, presetHistory: TemporaryScheduleOverrideHistory? = nil) { + @ObservationIgnored private let userDefaults: UserDefaults + + var showsActivityPresets: Bool { + didSet { + guard oldValue != showsActivityPresets else { + return + } + userDefaults.showsActivityPresets = showsActivityPresets + Task { @MainActor in + if !showsActivityPresets { + for activityType in ActivityPreset.ActivityType.allCases { + await alertIssuer?.retractAlert(identifier: Alert.Identifier(managerIdentifier: managerIdentifier, alertIdentifier: activityType.id)) + } + } + await scheduleNextPresetReminder() + } + notify(forChange: .preferences) + } + } + + init(settingsProvider: SettingsProvider, alertIssuer: AlertIssuer? = nil, presetHistory: TemporaryScheduleOverrideHistory? = nil, userDefaults: UserDefaults = .standard) { self.settingsProvider = settingsProvider self.alertIssuer = alertIssuer + self.userDefaults = userDefaults + self.showsActivityPresets = userDefaults.showsActivityPresets self.presetHistory = presetHistory ?? TemporaryScheduleOverrideHistoryContainer.shared.fetch() TemporaryScheduleOverrideHistory.relevantTimeWindow = Bundle.main.localCacheDuration @@ -165,6 +187,17 @@ class TemporaryPresetsManager { return presets } + /// The presets offered for selection; `selectablePresets` still resolves + /// hidden ones, so history and an already running preset keep their name. + var listedPresets: [SelectablePreset] { + selectablePresets.filter { preset in + if case .activity = preset { + return showsActivityPresets + } + return true + } + } + var clearOverrideTimer: Timer? public func scheduleClearOverride(override: TemporaryScheduleOverride) { clearOverrideTimer?.invalidate() @@ -409,7 +442,9 @@ class TemporaryPresetsManager { let now = now - let preset = settings.overridePresets.reduce(into: nil as TemporaryPreset?) { result, preset in + let schedulable = settings.overridePresets.filter { showsActivityPresets || ActivityPreset(preset: $0) == nil } + + let preset = schedulable.reduce(into: nil as TemporaryPreset?) { result, preset in if let nextScheduledTime = preset.nextScheduledStartAfter(now) { if result == nil || nextScheduledTime < (result!.nextScheduledStartAfter(now)!) { result = preset diff --git a/Loop/Managers/WatchDataManager.swift b/Loop/Managers/WatchDataManager.swift index 138fa5126a..c91c1fe48b 100644 --- a/Loop/Managers/WatchDataManager.swift +++ b/Loop/Managers/WatchDataManager.swift @@ -154,7 +154,8 @@ final class WatchDataManager: NSObject { private func sendSettingsIfNeeded() { let userInfo = LoopSettingsUserInfo( loopSettings: settingsManager.loopSettings, - scheduleOverride: temporaryPresetsManager.scheduleOverride) + scheduleOverride: temporaryPresetsManager.scheduleOverride, + showsActivityPresets: temporaryPresetsManager.showsActivityPresets) guard let session = watchSession, session.isPaired, session.isWatchAppInstalled else { return @@ -456,7 +457,8 @@ final class WatchDataManager: NSObject { case SettingsRequestUserInfo.name?: let userInfo = LoopSettingsUserInfo( loopSettings: settingsManager.loopSettings, - scheduleOverride: temporaryPresetsManager.scheduleOverride) + scheduleOverride: temporaryPresetsManager.scheduleOverride, + showsActivityPresets: temporaryPresetsManager.showsActivityPresets) return userInfo.rawValue case GetBolusRecommendationUserInfo.name?: if let request = GetBolusRecommendationUserInfo(rawValue: message) { diff --git a/Loop/Views/Presets/PresetsView.swift b/Loop/Views/Presets/PresetsView.swift index f3c28115a8..db474c6340 100644 --- a/Loop/Views/Presets/PresetsView.swift +++ b/Loop/Views/Presets/PresetsView.swift @@ -98,7 +98,7 @@ struct PresetsView: View { var isDescending: Bool { !presetsSortAscending } var presetsSorted: [SelectablePreset] { - temporaryPresetsManager.selectablePresets + temporaryPresetsManager.listedPresets .filter { $0.id != temporaryPresetsManager.activeOverride?.presetId } .sorted(by: { switch (selectedSortOption) { @@ -151,13 +151,7 @@ struct PresetsView: View { sortMenu } - Button(action: { - if trainingCompletion.isComplete { - presentCreateView = true - } else { - presentTrainingNeededAlert = true - } - }) { + Button(action: createPreset) { Image(systemName: "plus") } .foregroundStyle(trainingCompletion.isComplete ? Color.accentColor : Color.secondary) @@ -184,9 +178,13 @@ struct PresetsView: View { activeSheet = .presetDetent(preset) } } + + if presetsSorted.isEmpty { + createPresetButton + } } } - + // Support Section VStack(alignment: .leading, spacing: 16) { Text("Support") @@ -237,6 +235,15 @@ struct PresetsView: View { .frame(maxWidth: .infinity)) } } + + // Options Section + VStack(alignment: .leading, spacing: 16) { + Text("Options", comment: "Presets screen section header for display options") + .font(.headline.weight(.semibold)) + .padding(.horizontal, 10) + + activityPresetsToggle + } } .padding() .animation(.default, value: temporaryPresetsManager.activeOverride) @@ -322,8 +329,63 @@ struct PresetsView: View { .alert(isPresented: $presentTrainingNeededAlert) { trainingNeededAlert } + .onChange(of: temporaryPresetsManager.showsActivityPresets, initial: true) { _, showsActivityPresets in + trainingCompletion.exerciseRequired = showsActivityPresets + } } - + + private func createPreset() { + if trainingCompletion.isComplete { + presentCreateView = true + } else { + presentTrainingNeededAlert = true + } + } + + private var createPresetButton: some View { + Button(action: createPreset) { + VStack(spacing: 12) { + Image(systemName: "plus.circle.fill") + .font(.system(size: 44)) + Text("Create New Preset", comment: "Button shown in place of the preset list when there are no presets") + .font(.headline) + } + .foregroundStyle(trainingCompletion.isComplete ? Color.accentColor : Color.secondary) + .frame(maxWidth: .infinity) + .padding(.vertical, 32) + .background(RoundedRectangle(cornerRadius: 12) + .fill(Color(UIColor.tertiarySystemBackground))) + } + .accessibilityIdentifier("button_CreateNewPreset") + } + + private var activityPresetsToggle: some View { + VStack(alignment: .leading, spacing: 8) { + Toggle(isOn: Binding( + get: { temporaryPresetsManager.showsActivityPresets }, + set: { newValue in withAnimation { temporaryPresetsManager.showsActivityPresets = newValue } } + )) { + Text("Show Activity Presets", comment: "Title of the toggle that shows or hides the built-in activity presets") + } + .padding(10) + .background(RoundedRectangle(cornerRadius: 8) + .fill(Color(UIColor.tertiarySystemBackground)) + .stroke(Color(UIColor.secondarySystemBackground), lineWidth: 1) + .frame(maxWidth: .infinity)) + .accessibilityIdentifier("toggle_ShowActivityPresets") + + Text(activityPresetsFooter) + .font(.footnote) + .foregroundStyle(.secondary) + .padding(.horizontal, 10) + } + } + + private var activityPresetsFooter: String { + let names = ListFormatter.localizedString(byJoining: ActivityPreset.ActivityType.allCases.map(\.name)) + return String(format: NSLocalizedString("%1$@. While hidden, the Presets for Exercise training chapter is optional.", comment: "Footer under the Show Activity Presets toggle. (1: list of activity preset names)"), names) + } + private var trainingNeededAlert: SwiftUI.Alert { Alert(title: Text("Training Required for New Presets", comment: "Preset training needed alert title"), message: Text("To create a new preset, you must complete the required training.", comment: "Preset training needed alert message"), diff --git a/Loop/Views/Presets/Training/Components/PresetsTrainingCard.swift b/Loop/Views/Presets/Training/Components/PresetsTrainingCard.swift index ddd91eafac..63df4b4f43 100644 --- a/Loop/Views/Presets/Training/Components/PresetsTrainingCard.swift +++ b/Loop/Views/Presets/Training/Components/PresetsTrainingCard.swift @@ -16,7 +16,7 @@ public struct PresetsTrainingCard: View { public init(trainingCompletion: PresetsTrainingCompletion) { if trainingCompletion.completedChapters[.customizingPresets] != true { self.imageName = "PresetsTrainingCreditEditStartCard" - } else if trainingCompletion.completedChapters[.trainingComplete] != true { + } else if !trainingCompletion.isComplete { self.imageName = "PresetsTrainingCreditEditResumeCard" } else { self.imageName = nil diff --git a/Loop/Views/Presets/Training/PresetsTrainingContent.swift b/Loop/Views/Presets/Training/PresetsTrainingContent.swift index 04060aec3b..cff2c42de2 100644 --- a/Loop/Views/Presets/Training/PresetsTrainingContent.swift +++ b/Loop/Views/Presets/Training/PresetsTrainingContent.swift @@ -51,7 +51,9 @@ extension PresetsTraining.Step: PresetsTrainingContent { Text("How to configure each setting") Text("How to use Presets when you are sick") Text("How to use Presets for everyday activities") - Text("How to use Presets for exercise") + ExerciseOptionalReader { isOptional in + isOptional ? Text("How to use Presets for exercise (optional)") : Text("How to use Presets for exercise") + } } .padding(.leading, 8) } @@ -132,10 +134,12 @@ extension PresetsTraining.Step: PresetsTrainingContent { ) .disabled(true) - CommonUseStep( - title: Text("Presets for Exercise"), - readTime: .minutes(5) - ) + ExerciseOptionalReader { isOptional in + CommonUseStep( + title: isOptional ? Text("Presets for Exercise (Optional)") : Text("Presets for Exercise"), + readTime: .minutes(5) + ) + } .disabled(true) } @@ -265,10 +269,12 @@ extension PresetsTraining.Step: PresetsTrainingContent { onTapGesture: next ) - CommonUseStep( - title: Text("Presets for Exercise"), - readTime: .minutes(5) - ) + ExerciseOptionalReader { isOptional in + CommonUseStep( + title: isOptional ? Text("Presets for Exercise (Optional)") : Text("Presets for Exercise"), + readTime: .minutes(5) + ) + } .disabled(true) } @@ -371,11 +377,13 @@ extension PresetsTraining.Step: PresetsTrainingContent { readTime: .minutes(2) ) - CommonUseStep( - title: Text("Presets for Exercise"), - readTime: .minutes(5), - onTapGesture: next - ) + ExerciseOptionalReader { isOptional in + CommonUseStep( + title: isOptional ? Text("Presets for Exercise (Optional)") : Text("Presets for Exercise"), + readTime: .minutes(5), + onTapGesture: next + ) + } } case .presetsForExercise: Text("Exercise is a common reason to use a preset. Different kinds of exercise and their intensity levels can affect your glucose levels in different ways.") @@ -443,15 +451,8 @@ extension PresetsTraining.Step: PresetsTrainingContent { VStack(alignment: .leading, spacing: 4) { Text("Walking") - - HStack(alignment: .center, spacing: 2) { - Text("\(Image(systemName: "lightbulb.max"))") - - Text(" **Tip** Use your \(Image(systemName: "figure.walk")) **Walking** preset") - } - .padding(.vertical, 4) - .padding(.horizontal, 6) - .background(Color(UIColor.secondarySystemBackground).cornerRadius(5)) + + ActivityPresetTip(Text(" **Tip** Use your \(Image(systemName: "figure.walk")) **Walking** preset")) } } .frame(maxWidth: .infinity, alignment: .leading) @@ -468,15 +469,8 @@ extension PresetsTraining.Step: PresetsTrainingContent { VStack(alignment: .leading, spacing: 4) { Text("Jogging") - - HStack(alignment: .center, spacing: 2) { - Text("\(Image(systemName: "lightbulb.max"))") - - Text(" **Tip** Use your \(Image(systemName: "figure.run")) **Jogging** preset") - } - .padding(.vertical, 4) - .padding(.horizontal, 6) - .background(Color(UIColor.secondarySystemBackground).cornerRadius(5)) + + ActivityPresetTip(Text(" **Tip** Use your \(Image(systemName: "figure.run")) **Jogging** preset")) } } .frame(maxWidth: .infinity, alignment: .leading) @@ -962,6 +956,12 @@ extension PresetsTraining.Step: PresetsTrainingContent { } Text("You may review the training materials again at any time via the Learning Hub, located at the bottom of the Preset screen.") + + ExerciseOptionalReader { isOptional, isExerciseComplete in + if isOptional && !isExerciseComplete { + Text("Presets for Exercise is optional. The Learning Hub opens it whenever you want to read it.") + } + } } } @@ -1117,31 +1117,47 @@ extension PresetsTraining.Chapter: PresetsTrainingContent { } } -extension ActivityPreset.ActivityType { - func bulletItem(full: Bool) -> Text { - if full { - return Text(Image(systemName: systemImageName)) - .fontDesign(.monospaced) - + Text(" \(name) ยท ") - .fontWeight(.semibold) - + Text("\(defaultInsulinNeedsScaleFactor.formatted(.percent)) of insulin") - } else { - return Text(Image(systemName: systemImageName)) - .fontDesign(.monospaced) - + Text(" \(name)") - .fontWeight(.semibold) - } +private struct ExerciseOptionalReader: View { + @Environment(PresetsTraining.self) private var training + + private let content: (_ isOptional: Bool, _ isExerciseComplete: Bool) -> Content + + init(@ViewBuilder content: @escaping (_ isOptional: Bool, _ isExerciseComplete: Bool) -> Content) { + self.content = content + } + + init(@ViewBuilder content: @escaping (_ isOptional: Bool) -> Content) { + self.content = { isOptional, _ in content(isOptional) } + } + + var body: some View { + content( + !training.trainingCompletion.isRequired(.exercise), + training.trainingCompletion.completedChapters[.exercise] == true + ) } } -extension ActivityPreset { - @ViewBuilder - static func bulletList(full: Bool) -> some View { - BulletedListView { - ActivityPreset.ActivityType.jogging.bulletItem(full: full) - ActivityPreset.ActivityType.walking.bulletItem(full: full) - ActivityPreset.ActivityType.biking.bulletItem(full: full) - ActivityPreset.ActivityType.strengthTraining.bulletItem(full: full) +/// Points at a built-in activity preset, so it is left out while those are hidden. +private struct ActivityPresetTip: View { + @Environment(PresetsTraining.self) private var training + + private let text: Text + + init(_ text: Text) { + self.text = text + } + + var body: some View { + if training.trainingCompletion.exerciseRequired { + HStack(alignment: .center, spacing: 2) { + Text("\(Image(systemName: "lightbulb.max"))") + + text + } + .padding(.vertical, 4) + .padding(.horizontal, 6) + .background(Color(UIColor.secondarySystemBackground).cornerRadius(5)) } } } diff --git a/LoopCore/Models/LoopSettingsUserInfo.swift b/LoopCore/Models/LoopSettingsUserInfo.swift index 860e31bdf8..6f58e7a5d4 100644 --- a/LoopCore/Models/LoopSettingsUserInfo.swift +++ b/LoopCore/Models/LoopSettingsUserInfo.swift @@ -10,10 +10,12 @@ import LoopKit public struct LoopSettingsUserInfo: Equatable { public var loopSettings: LoopSettings public var scheduleOverride: TemporaryScheduleOverride? + public var showsActivityPresets: Bool - public init(loopSettings: LoopSettings, scheduleOverride: TemporaryScheduleOverride? = nil) { + public init(loopSettings: LoopSettings, scheduleOverride: TemporaryScheduleOverride? = nil, showsActivityPresets: Bool = true) { self.loopSettings = loopSettings self.scheduleOverride = scheduleOverride + self.showsActivityPresets = showsActivityPresets } } @@ -39,13 +41,16 @@ extension LoopSettingsUserInfo: RawRepresentable { } else { self.scheduleOverride = nil } + + self.showsActivityPresets = rawValue["a"] as? Bool ?? true } public var rawValue: RawValue { var raw: RawValue = [ "v": LoopSettingsUserInfo.version, "name": LoopSettingsUserInfo.name, - "s": loopSettings.rawValue + "s": loopSettings.rawValue, + "a": showsActivityPresets ] raw["o"] = scheduleOverride?.rawValue diff --git a/LoopTests/Managers/TemporaryPresetsManagerTests.swift b/LoopTests/Managers/TemporaryPresetsManagerTests.swift index 8c8392c97e..a9e721d43b 100644 --- a/LoopTests/Managers/TemporaryPresetsManagerTests.swift +++ b/LoopTests/Managers/TemporaryPresetsManagerTests.swift @@ -8,6 +8,7 @@ import XCTest import LoopKit +import LoopCore @testable import Loop @@ -94,4 +95,30 @@ class TemporaryPresetsManagerTests: XCTestCase { let actualOverrideRange = manager.effectiveCorrectionRangeSchedule()?.value(at: overrideStart.addingTimeInterval(2 /* hours */ * 60 * 60)) XCTAssertEqual(actualOverrideRange, overrideTargetRange) } + + func testHidingActivityPresetsOnlyRemovesThemFromTheList() { + let userDefaults = UserDefaults(suiteName: UUID().uuidString)! + let manager = TemporaryPresetsManager(settingsProvider: MockSettingsProvider(settings: settings), presetHistory: TemporaryScheduleOverrideHistory(), userDefaults: userDefaults) + let activityIDs = ActivityPreset.ActivityType.allCases.map(\.id) + XCTAssertTrue(manager.showsActivityPresets) + XCTAssertEqual(manager.listedPresets.filter { activityIDs.contains($0.id) }.count, activityIDs.count) + + manager.showsActivityPresets = false + + XCTAssertTrue(manager.listedPresets.allSatisfy { !activityIDs.contains($0.id) }) + XCTAssertTrue(manager.listedPresets.contains { $0.id == "preMeal" }) + XCTAssertEqual(manager.selectablePresets.filter { activityIDs.contains($0.id) }.count, activityIDs.count, "hidden presets still resolve by id") + + let relaunched = TemporaryPresetsManager(settingsProvider: MockSettingsProvider(settings: settings), presetHistory: TemporaryScheduleOverrideHistory(), userDefaults: userDefaults) + XCTAssertFalse(relaunched.showsActivityPresets) + } + + func testSettingsUserInfoCarriesActivityPresetVisibility() { + let hidden = LoopSettingsUserInfo(loopSettings: LoopSettings(), showsActivityPresets: false) + XCTAssertEqual(LoopSettingsUserInfo(rawValue: hidden.rawValue)?.showsActivityPresets, false) + + var fromOlderPhone = hidden.rawValue + fromOlderPhone["a"] = nil + XCTAssertEqual(LoopSettingsUserInfo(rawValue: fromOlderPhone)?.showsActivityPresets, true) + } } diff --git a/WatchApp Extension/Managers/LoopDataManager.swift b/WatchApp Extension/Managers/LoopDataManager.swift index 1746cf8ce8..b4987cf7cc 100644 --- a/WatchApp Extension/Managers/LoopDataManager.swift +++ b/WatchApp Extension/Managers/LoopDataManager.swift @@ -308,6 +308,15 @@ extension LoopDataManager { return presets } + var listedPresets: [SelectablePreset] { + selectablePresets.filter { preset in + if case .activity = preset { + return watchInfo.showsActivityPresets + } + return true + } + } + var glucoseValue: String { guard let activeContext = activeContext, let glucose = activeContext.glucose, diff --git a/WatchApp Extension/Views/PresetsView.swift b/WatchApp Extension/Views/PresetsView.swift index af738c2390..410ea285ca 100644 --- a/WatchApp Extension/Views/PresetsView.swift +++ b/WatchApp Extension/Views/PresetsView.swift @@ -35,7 +35,7 @@ struct PresetsView: View { PresetConfirmationView(preset: loopManager.selectablePresets.first {$0.id == override.presetId}) case .presetsList: NavigationStack(path: $path) { - PresetListView(presets: loopManager.selectablePresets, path: $path) + PresetListView(presets: loopManager.listedPresets, path: $path) } } }