Explorar o código

Block alarm picker when .always covers a type; split into Available + Unavailable

trioneer hai 2 semanas
pai
achega
09c4fce5cd

+ 59 - 30
Trio/Sources/Modules/GlucoseAlerts/View/AddGlucoseAlertSheet.swift

@@ -11,35 +11,25 @@ struct AddGlucoseAlertSheet: View {
     var body: some View {
         NavigationStack {
             List {
-                Section {
-                    ForEach(GlucoseAlertType.allCases) { type in
-                        let available = store.availableActiveOptions(forNewAlarmOfType: type)
-                        let isAvailable = !available.isEmpty
-                        Button {
-                            guard isAvailable else { return }
-                            onPick(type)
-                            dismiss()
-                        } label: {
-                            HStack(spacing: 12) {
-                                VStack(alignment: .leading, spacing: 2) {
-                                    Text(type.displayName)
-                                        .foregroundColor(.primary)
-                                    Text(isAvailable ? type.blurb : unavailableHint(forType: type))
-                                        .font(.footnote)
-                                        .foregroundColor(.secondary)
-                                }
-                                Spacer()
-                                if isAvailable {
-                                    Image(systemName: "chevron.right")
-                                        .font(.footnote)
-                                        .foregroundColor(.secondary)
-                                }
-                            }
+                if !availableTypes.isEmpty {
+                    Section(header: Text("Available")) {
+                        ForEach(availableTypes) { type in
+                            availableRow(for: type)
                         }
-                        .disabled(!isAvailable)
-                        .opacity(isAvailable ? 1 : 0.5)
-                    }
-                }.listRowBackground(Color.chart)
+                    }.listRowBackground(Color.chart)
+                }
+                if !unavailableTypes.isEmpty {
+                    Section(
+                        header: Text("Unavailable"),
+                        footer: Text(
+                            "Already set for Day & Night. Change the alarm time window to add a second alarm for the same type."
+                        )
+                    ) {
+                        ForEach(unavailableTypes) { type in
+                            unavailableRow(for: type)
+                        }
+                    }.listRowBackground(Color.chart)
+                }
             }
             .scrollContentBackground(.hidden).background(appState.trioBackgroundColor(for: colorScheme))
             .navigationTitle("Add Alarm")
@@ -52,7 +42,46 @@ struct AddGlucoseAlertSheet: View {
         }
     }
 
-    private func unavailableHint(forType type: GlucoseAlertType) -> String {
-        String(localized: "Already set for Day & Night. Delete one of these to add a new one.")
+    private var availableTypes: [GlucoseAlertType] {
+        GlucoseAlertType.allCases.filter { !store.availableActiveOptions(forNewAlarmOfType: $0).isEmpty }
+    }
+
+    private var unavailableTypes: [GlucoseAlertType] {
+        GlucoseAlertType.allCases.filter { store.availableActiveOptions(forNewAlarmOfType: $0).isEmpty }
+    }
+
+    private func availableRow(for type: GlucoseAlertType) -> some View {
+        Button {
+            onPick(type)
+            dismiss()
+        } label: {
+            HStack(spacing: 12) {
+                VStack(alignment: .leading, spacing: 2) {
+                    Text(type.displayName)
+                        .foregroundColor(.primary)
+                    Text(type.blurb)
+                        .font(.footnote)
+                        .foregroundColor(.secondary)
+                }
+                Spacer()
+                Image(systemName: "chevron.right")
+                    .font(.footnote)
+                    .foregroundColor(.secondary)
+            }
+        }
+    }
+
+    private func unavailableRow(for type: GlucoseAlertType) -> some View {
+        HStack(spacing: 12) {
+            VStack(alignment: .leading, spacing: 2) {
+                Text(type.displayName)
+                    .foregroundColor(.primary)
+                Text(type.blurb)
+                    .font(.footnote)
+                    .foregroundColor(.secondary)
+            }
+            Spacer()
+        }
+        .opacity(0.5)
     }
 }

+ 1 - 0
Trio/Sources/Modules/GlucoseAlerts/View/GlucoseAlertEditorView.swift

@@ -15,6 +15,7 @@ struct GlucoseAlertEditorView: View {
         )
         return ActiveOption.allCases.filter { available.contains($0) }
     }
+
     let units: GlucoseUnits
     var onDone: () -> Void
     var onCancel: () -> Void

+ 6 - 0
Trio/Sources/Services/Alerts/GlucoseAlertsStore.swift

@@ -115,8 +115,14 @@ final class GlucoseAlertsStore: ObservableObject {
                 .filter { $0.type == type && $0.id != excludedID }
                 .map(\.activeOption)
         )
+        // `.always` covers both windows — fully blocks all additions.
+        if taken.contains(.always) { return [] }
+        // `.day` + `.night` together also cover everything.
+        if taken.contains(.day), taken.contains(.night) { return [] }
         var available = Set(ActiveOption.allCases)
         available.subtract(taken)
+        // Anything already taken (even just `.day` or `.night`) makes
+        // `.always` redundant — pull it out so it's not an option.
         if !taken.isEmpty { available.remove(.always) }
         return available
     }

+ 68 - 0
TrioTests/GlucoseAlertsStoreTests.swift

@@ -61,4 +61,72 @@ import Testing
             #expect(countsByType[type] == 1, "Duplicated \(type) on load")
         }
     }
+
+    // MARK: - availableActiveOptions for new alarms
+
+    private static func storeWithOnly(_ alerts: [GlucoseAlert]) -> GlucoseAlertsStore {
+        let store = Self.makeStore()
+        store.alerts = alerts
+        return store
+    }
+
+    @Test("Single .always covers everything: no options available") func alwaysCoversAll() {
+        let existing = GlucoseAlert(type: .low) // default .always
+        let store = Self.storeWithOnly([existing])
+        #expect(store.availableActiveOptions(forNewAlarmOfType: .low).isEmpty)
+    }
+
+    @Test("Only .day taken → only .night available") func dayTakenOffersNight() {
+        var existing = GlucoseAlert(type: .low)
+        existing.activeOption = .day
+        let store = Self.storeWithOnly([existing])
+        #expect(store.availableActiveOptions(forNewAlarmOfType: .low) == [.night])
+    }
+
+    @Test("Only .night taken → only .day available") func nightTakenOffersDay() {
+        var existing = GlucoseAlert(type: .high)
+        existing.activeOption = .night
+        let store = Self.storeWithOnly([existing])
+        #expect(store.availableActiveOptions(forNewAlarmOfType: .high) == [.day])
+    }
+
+    @Test(".day + .night both taken → none available") func dayAndNightTakenLocked() {
+        var day = GlucoseAlert(type: .forecastedLow)
+        day.activeOption = .day
+        var night = GlucoseAlert(type: .forecastedLow)
+        night.activeOption = .night
+        let store = Self.storeWithOnly([day, night])
+        #expect(store.availableActiveOptions(forNewAlarmOfType: .forecastedLow).isEmpty)
+    }
+
+    @Test("No alarm of this type → all three options available") func emptyOffersAll() {
+        let store = Self.storeWithOnly([])
+        #expect(store.availableActiveOptions(forNewAlarmOfType: .carbsRequired) == [.always, .day, .night])
+    }
+
+    @Test("Gating is per-type — other types don't block") func gatingIsPerType() {
+        let lowAlways = GlucoseAlert(type: .low) // .always blocks Low
+        let store = Self.storeWithOnly([lowAlways])
+        #expect(store.availableActiveOptions(forNewAlarmOfType: .low).isEmpty)
+        #expect(store.availableActiveOptions(forNewAlarmOfType: .high) == [.always, .day, .night])
+    }
+
+    @Test("Editing an existing .day alarm: .always becomes pickable again") func editingExcludesSelf() {
+        var existing = GlucoseAlert(type: .urgentLow)
+        existing.activeOption = .day
+        let store = Self.storeWithOnly([existing])
+        let available = store.availableActiveOptions(forType: .urgentLow, excludingAlertID: existing.id)
+        #expect(available == [.always, .day, .night])
+    }
+
+    @Test("Editing one of two split alarms: only own window available") func editingOneOfTwoSplit() {
+        var day = GlucoseAlert(type: .low)
+        day.activeOption = .day
+        var night = GlucoseAlert(type: .low)
+        night.activeOption = .night
+        let store = Self.storeWithOnly([day, night])
+        // Editing the .day alarm: .night is taken by the other, .always would conflict with .night.
+        let editingDay = store.availableActiveOptions(forType: .low, excludingAlertID: day.id)
+        #expect(editingDay == [.day])
+    }
 }