Przeglądaj źródła

Add scroll-to and highlight for settings search results

Settings search can now scroll to the matched setting and briefly
highlight it. Implemented for Algorithm > DynamicSettings as a starting
point. Remaining screens to follow.
Gordon Child 1 miesiąc temu
rodzic
commit
01d50dcb29

+ 8 - 0
Trio.xcodeproj/project.pbxproj

@@ -342,6 +342,7 @@
 		9702FF92A09C53942F20D7EA /* TargetsEditorRootView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4DD795BA46B193644D48138C /* TargetsEditorRootView.swift */; };
 		9825E5E923F0B8FA80C8C7C7 /* NightscoutConfigStateModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = A0A48AE3AC813A49A517846A /* NightscoutConfigStateModel.swift */; };
 		98641AF4F92123DA668AB931 /* CarbRatioEditorRootView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0BDC6993C1087310EDFC428 /* CarbRatioEditorRootView.swift */; };
+		41740E936552456AAC0EDAC3 /* SettingsSearchTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = B3919BBB515547118D684CA2 /* SettingsSearchTests.swift */; };
 		A33352ED40476125EBAC6EE0 /* CarbRatioEditorDataFlow.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7E22146D3DF4853786C78132 /* CarbRatioEditorDataFlow.swift */; };
 		AD3D2CD42CD01B9EB8F26522 /* PumpConfigDataFlow.swift in Sources */ = {isa = PBXBuildFile; fileRef = AF65DA88F972B56090AD6AC3 /* PumpConfigDataFlow.swift */; };
 		B7C465E9472624D8A2BE2A6A /* (null) in Sources */ = {isa = PBXBuildFile; };
@@ -543,6 +544,7 @@
 		DD1745242C55526000211FAC /* SMBSettingsStateModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD1745232C55526000211FAC /* SMBSettingsStateModel.swift */; };
 		DD1745262C55526F00211FAC /* SMBSettingsRootView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD1745252C55526F00211FAC /* SMBSettingsRootView.swift */; };
 		DD1745292C55642100211FAC /* SettingInputSection.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD1745282C55642100211FAC /* SettingInputSection.swift */; };
+		AC19EF2C94084B5BA0175D1D /* SettingsSearchHighlight.swift in Sources */ = {isa = PBXBuildFile; fileRef = 48B83503461B4F8D97B30115 /* SettingsSearchHighlight.swift */; };
 		DD17452B2C556E8100211FAC /* SettingInputHintView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD17452A2C556E8100211FAC /* SettingInputHintView.swift */; };
 		DD17452E2C55AE4800211FAC /* TargetBehavoirDataFlow.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD17452D2C55AE4800211FAC /* TargetBehavoirDataFlow.swift */; };
 		DD1745302C55AE5300211FAC /* TargetBehaviorProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD17452F2C55AE5300211FAC /* TargetBehaviorProvider.swift */; };
@@ -1388,6 +1390,7 @@
 		DD1745232C55526000211FAC /* SMBSettingsStateModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SMBSettingsStateModel.swift; sourceTree = "<group>"; };
 		DD1745252C55526F00211FAC /* SMBSettingsRootView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SMBSettingsRootView.swift; sourceTree = "<group>"; };
 		DD1745282C55642100211FAC /* SettingInputSection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingInputSection.swift; sourceTree = "<group>"; };
+		48B83503461B4F8D97B30115 /* SettingsSearchHighlight.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsSearchHighlight.swift; sourceTree = "<group>"; };
 		DD17452A2C556E8100211FAC /* SettingInputHintView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingInputHintView.swift; sourceTree = "<group>"; };
 		DD17452D2C55AE4800211FAC /* TargetBehavoirDataFlow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TargetBehavoirDataFlow.swift; sourceTree = "<group>"; };
 		DD17452F2C55AE5300211FAC /* TargetBehaviorProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TargetBehaviorProvider.swift; sourceTree = "<group>"; };
@@ -1570,6 +1573,7 @@
 		E592A3742CEEC038009A472C /* ContactImageProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContactImageProvider.swift; sourceTree = "<group>"; };
 		E592A3752CEEC038009A472C /* ContactImageStateModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContactImageStateModel.swift; sourceTree = "<group>"; };
 		E625985B47742D498CB1681A /* GlucoseNotificationSettingsProvider.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = GlucoseNotificationSettingsProvider.swift; sourceTree = "<group>"; };
+		B3919BBB515547118D684CA2 /* SettingsSearchTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = SettingsSearchTests.swift; sourceTree = "<group>"; };
 		F816825D28DB441200054060 /* HeartBeatManager.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = HeartBeatManager.swift; sourceTree = "<group>"; };
 		F816825F28DB441800054060 /* BluetoothTransmitter.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = BluetoothTransmitter.swift; sourceTree = "<group>"; };
 		F90692A9274B7AAE0037068D /* HealthKitManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HealthKitManager.swift; sourceTree = "<group>"; };
@@ -2322,6 +2326,7 @@
 				38DF1785276A73D400B3528F /* TagCloudView.swift */,
 				DD88C8E12C50420800F2D558 /* DefinitionRow.swift */,
 				DD1745282C55642100211FAC /* SettingInputSection.swift */,
+				48B83503461B4F8D97B30115 /* SettingsSearchHighlight.swift */,
 				DD17452A2C556E8100211FAC /* SettingInputHintView.swift */,
 			);
 			path = Views;
@@ -2639,6 +2644,7 @@
 				38FCF3F825E902C20078B0D1 /* FileStorageTests.swift */,
 				3B997DCE2DC00A3A006B6BB2 /* JSONImporterTests.swift */,
 				CE1F6DD82BADF4620064EB8D /* PluginManagerTests.swift */,
+				B3919BBB515547118D684CA2 /* SettingsSearchTests.swift */,
 				BD8FC0532D66186000B95AED /* TestError.swift */,
 			);
 			path = TrioTests;
@@ -4304,6 +4310,7 @@
 				BDF34F832C10C5B600D51995 /* DataManager.swift in Sources */,
 				38B4F3C625E5017E00E76A18 /* NotificationCenter.swift in Sources */,
 				19D466A729AA2C22004D5F33 /* MealSettingsStateModel.swift in Sources */,
+				AC19EF2C94084B5BA0175D1D /* SettingsSearchHighlight.swift in Sources */,
 				DD17452B2C556E8100211FAC /* SettingInputHintView.swift in Sources */,
 				38E44528274E401C00EC9A94 /* Protected.swift in Sources */,
 				DD3F1F8B2D9E08B600DCE7B3 /* NightscoutLoginStepView.swift in Sources */,
@@ -4793,6 +4800,7 @@
 				38FCF3F925E902C20078B0D1 /* FileStorageTests.swift in Sources */,
 				BD8FC0602D6619DB00B95AED /* CarbsStorageTests.swift in Sources */,
 				BD8FC05E2D6618CE00B95AED /* BolusCalculatorTests.swift in Sources */,
+				41740E936552456AAC0EDAC3 /* SettingsSearchTests.swift in Sources */,
 			);
 			runOnlyForDeploymentPostprocessing = 0;
 		};

+ 2 - 1
Trio/Sources/Modules/DynamicSettings/View/DynamicSettingsRootView.swift

@@ -135,7 +135,7 @@ extension DynamicSettings {
                             }.padding(.top)
                         }.padding(.bottom)
                     }
-                ).listRowBackground(Color.chart)
+                ).settingsSearchTarget(label: String(localized: "Dynamic ISF"))
 
                 if state.dynamicSensitivityType != .disabled {
                     if state.dynamicSensitivityType == .logarithmic {
@@ -277,6 +277,7 @@ extension DynamicSettings {
             .onAppear(perform: configureView)
             .navigationBarTitle("Dynamic Settings")
             .navigationBarTitleDisplayMode(.automatic)
+            .settingsHighlightScroll()
         }
     }
 }

+ 2 - 0
Trio/Sources/Modules/Home/View/HomeRootView.swift

@@ -22,6 +22,7 @@ extension Home {
         @State var state = StateModel()
 
         @State var settingsPath = NavigationPath()
+        @State var settingsSearchHighlight = SettingsSearchHighlight()
         @State var isStatusPopupPresented = false
         @State var showCancelAlert = false
         @State var showCancelConfirmDialog = false
@@ -1118,6 +1119,7 @@ extension Home {
 
                     NavigationStack(path: self.$settingsPath) {
                         Settings.RootView(resolver: resolver) }
+                        .environment(settingsSearchHighlight)
                         .tabItem { Label(
                             "Settings",
                             systemImage: "gear"

+ 11 - 0
Trio/Sources/Modules/Settings/SettingItems.swift

@@ -8,16 +8,22 @@ struct SettingItem: Identifiable {
     let view: Screen
     let searchContents: [String]?
     let path: [String]?
+    /// Maps a `searchContents` string to the exact label used in `SettingInputSection`
+    /// when the two differ (e.g. `"Max IOB"` → `"Maximum Insulin on Board (IOB)"`).
+    /// Entries whose searchContents string already matches the label don't need an entry here.
+    let scrollTargetLabels: [String: String]?
 
     init(
         title: String,
         view: Screen,
         searchContents: [String]? = nil,
+        scrollTargetLabels: [String: String]? = nil,
         path: [String]? = nil
     ) {
         self.title = title
         self.view = view
         self.searchContents = searchContents
+        self.scrollTargetLabels = scrollTargetLabels
         self.path = path
     }
 }
@@ -26,6 +32,11 @@ struct FilteredSettingItem: Identifiable {
     let id = UUID()
     let settingItem: SettingItem
     let matchedContent: String
+    /// The label string used as the scroll/highlight target in the destination view.
+    /// Falls back to `matchedContent` when no explicit mapping exists.
+    var scrollLabel: String {
+        settingItem.scrollTargetLabels?[matchedContent] ?? matchedContent
+    }
 }
 
 enum SettingItems {

+ 19 - 8
Trio/Sources/Modules/Settings/View/SettingsRootView.swift

@@ -38,6 +38,7 @@ extension Settings {
         @Environment(\.colorScheme) var colorScheme
         @EnvironmentObject var appIcons: Icons
         @Environment(AppState.self) var appState
+        @Environment(SettingsSearchHighlight.self) var searchHighlight
 
         private var filteredItems: [FilteredSettingItem] {
             SettingItems.filteredItems(searchText: searchText)
@@ -284,15 +285,19 @@ extension Settings {
                         content: {
                             if filteredItems.isNotEmpty {
                                 ForEach(filteredItems) { filteredItem in
-                                    VStack(alignment: .leading) {
-                                        Text(filteredItem.matchedContent.localized).bold()
-                                        if let path = filteredItem.settingItem.path {
-                                            Text(path.map(\.localized).joined(separator: " > "))
-                                                .font(.caption)
-                                                .foregroundColor(.secondary)
+                                    NavigationLink(value: SearchResultTarget(
+                                        screen: filteredItem.settingItem.view,
+                                        scrollLabel: filteredItem.scrollLabel.localized
+                                    )) {
+                                        VStack(alignment: .leading) {
+                                            Text(filteredItem.matchedContent.localized).bold()
+                                            if let path = filteredItem.settingItem.path {
+                                                Text(path.map(\.localized).joined(separator: " > "))
+                                                    .font(.caption)
+                                                    .foregroundColor(.secondary)
+                                            }
                                         }
-
-                                    }.navigationLink(to: filteredItem.settingItem.view, from: self)
+                                    }
                                 }
                             } else {
                                 Text("No settings matching your search query")
@@ -339,6 +344,12 @@ extension Settings {
                 }
             }
             .searchable(text: $searchText, placement: .navigationBarDrawer(displayMode: .always))
+            .navigationDestination(for: SearchResultTarget.self) { target in
+                state.view(for: target.screen)
+                    .onAppear {
+                        searchHighlight.highlightedSetting = target.scrollLabel
+                    }
+            }
             .screenNavigation(self)
             .onAppear {
                 Task { @MainActor in

+ 2 - 1
Trio/Sources/Views/SettingInputSection.swift

@@ -83,7 +83,8 @@ struct SettingInputSection<VerboseHint: View>: View {
             },
             header: { headerText.map(Text.init) },
             footer: { footerText.map(Text.init) }
-        ).listRowBackground(Color.chart)
+        )
+        .settingsSearchTarget(label: label)
     }
 
     // Helper function to retrieve PickerSetting based on key

+ 85 - 0
Trio/Sources/Views/SettingsSearchHighlight.swift

@@ -0,0 +1,85 @@
+import SwiftUI
+
+@Observable final class SettingsSearchHighlight {
+    var highlightedSetting: String?
+}
+
+/// Wraps a Screen value with the scroll-target label for search-result navigation.
+struct SearchResultTarget: Hashable {
+    let screen: Screen
+    let scrollLabel: String
+}
+
+private struct SettingsHighlightScrollModifier: ViewModifier {
+    @Environment(SettingsSearchHighlight.self) private var searchHighlight
+
+    func body(content: Content) -> some View {
+        ScrollViewReader { proxy in
+            content
+                .onAppear {
+                    scrollToHighlight(proxy: proxy)
+                }
+                .onChange(of: searchHighlight.highlightedSetting) { _, newValue in
+                    if newValue != nil {
+                        scrollToHighlight(proxy: proxy)
+                    }
+                }
+        }
+    }
+
+    private func scrollToHighlight(proxy: ScrollViewProxy) {
+        guard let target = searchHighlight.highlightedSetting else { return }
+        Task { @MainActor in
+            // Give the view time to finish initial layout
+            try? await Task.sleep(nanoseconds: 400_000_000)
+            withAnimation { proxy.scrollTo(target, anchor: .center) }
+            // Wait for scroll animation + newly-visible section's onAppear to fire
+            try? await Task.sleep(nanoseconds: 500_000_000)
+            searchHighlight.highlightedSetting = nil
+        }
+    }
+}
+
+private struct SettingsSearchHighlightAnimationModifier: ViewModifier {
+    let label: String
+    @Environment(SettingsSearchHighlight.self) private var searchHighlight
+    @State private var highlightOpacity: Double = 0.0
+
+    func body(content: Content) -> some View {
+        content
+            .listRowBackground(
+                Color.chart.overlay(Color.accentColor.opacity(highlightOpacity))
+                    .animation(.easeOut(duration: 1.2), value: highlightOpacity)
+            )
+            .onAppear {
+                guard searchHighlight.highlightedSetting == label else { return }
+                startHighlightAnimation()
+            }
+            .onChange(of: searchHighlight.highlightedSetting) { _, newValue in
+                guard newValue == label else { return }
+                startHighlightAnimation()
+            }
+    }
+
+    private func startHighlightAnimation() {
+        highlightOpacity = 0.6
+        Task { @MainActor in
+            try? await Task.sleep(nanoseconds: 1_000_000_000)
+            highlightOpacity = 0.0
+        }
+    }
+}
+
+extension View {
+    /// Enables scroll-to-highlight on a settings screen. Add once per destination view.
+    func settingsHighlightScroll() -> some View {
+        modifier(SettingsHighlightScrollModifier())
+    }
+
+    /// Marks a section as a scroll-to and highlight target for settings search.
+    /// Combines `.id(label)` with a highlight flash animation in a single call.
+    func settingsSearchTarget(label: String) -> some View {
+        id(label)
+            .modifier(SettingsSearchHighlightAnimationModifier(label: label))
+    }
+}

+ 69 - 0
TrioTests/SettingsSearchTests.swift

@@ -0,0 +1,69 @@
+import Foundation
+import Testing
+@testable import Trio
+
+@Suite("Settings Search Navigation") struct SettingsSearchTests {
+    @Test("Searching 'Dynamic ISF' finds the Dynamic Settings screen") func searchDynamicISF() {
+        let results = SettingItems.filteredItems(searchText: "Dynamic ISF")
+        #expect(!results.isEmpty)
+        let match = results.first { $0.matchedContent == "Dynamic ISF" }
+        #expect(match != nil)
+        #expect(match?.settingItem.view == .dynamicISF)
+        #expect(match?.scrollLabel == "Dynamic ISF")
+    }
+
+    @Test("All scrollTargetLabels have valid non-empty targets") func scrollTargetLabelsNonEmpty() {
+        for item in SettingItems.allItems {
+            guard let labels = item.scrollTargetLabels else { continue }
+            for (key, value) in labels {
+                #expect(!value.isEmpty)
+                #expect(item.searchContents?.contains(key) == true)
+            }
+        }
+    }
+
+    @Test("Every searchContents entry produces at least one result") func allSearchContentsAreSearchable() {
+        for item in SettingItems.allItems {
+            guard let contents = item.searchContents else { continue }
+            for content in contents {
+                let results = SettingItems.filteredItems(searchText: content)
+                #expect(!results.isEmpty)
+            }
+        }
+    }
+
+    @Test("SearchResultTarget is Hashable and equatable by value") func searchResultTargetHashable() {
+        let a = SearchResultTarget(screen: .dynamicISF, scrollLabel: "Dynamic ISF")
+        let b = SearchResultTarget(screen: .dynamicISF, scrollLabel: "Dynamic ISF")
+        let c = SearchResultTarget(screen: .dynamicISF, scrollLabel: "Adjust Basal")
+        #expect(a == b)
+        #expect(a != c)
+        #expect(a.hashValue == b.hashValue)
+    }
+
+    @Test("SettingsSearchHighlight starts nil and accepts assignments")
+    @MainActor func highlightStateTransitions() {
+        let highlight = SettingsSearchHighlight()
+        #expect(highlight.highlightedSetting == nil)
+
+        highlight.highlightedSetting = "Dynamic ISF"
+        #expect(highlight.highlightedSetting == "Dynamic ISF")
+
+        highlight.highlightedSetting = nil
+        #expect(highlight.highlightedSetting == nil)
+    }
+
+    @Test("SettingsSearchHighlight can be set and cleared in sequence")
+    @MainActor func highlightSequentialUpdates() async {
+        let highlight = SettingsSearchHighlight()
+
+        highlight.highlightedSetting = "First Setting"
+        #expect(highlight.highlightedSetting == "First Setting")
+
+        highlight.highlightedSetting = "Second Setting"
+        #expect(highlight.highlightedSetting == "Second Setting")
+
+        highlight.highlightedSetting = nil
+        #expect(highlight.highlightedSetting == nil)
+    }
+}