Przeglądaj źródła

Add search to the Menu (#695)

* Add search to the Menu covering Settings sub-items

Make SettingsRoute the single source of truth for the Settings list by
moving each row's title, icon and search keywords onto the route, and
grouping them via menuSections(nightscoutConfigured:). SettingsMenuView
now renders from that data.

Add .searchable to the Menu. Search results are assembled from the same
sources that build the menu — the Settings routes, the tab features and
the static rows — so they stay in sync automatically. A settings result
deep-links straight into its sub-screen; features, log and support-link
results reuse the menu's existing navigation.

* Make bottom-level settings findable in Menu search

Add a leaves list to SettingsRoute describing the individual settings
inside each sub-screen (row titles plus search synonyms), and include
them in the Menu search index after the screen-level items. A leaf
result shows a Settings → <screen> subtitle and opens the screen that
contains the setting, e.g. searching "basal" finds Graph Basal under
Advanced. Info Display leaves are derived from InfoType so they follow
the app's info rows automatically. NavigationRow gains an optional
subtitle to render these results.
Jonas Björkert 1 tydzień temu
rodzic
commit
afc1c645f6

+ 9 - 1
LoopFollow/Helpers/Views/NavigationRow.swift

@@ -5,6 +5,7 @@ import SwiftUI
 
 struct NavigationRow<Value: Hashable>: View {
     let title: String
+    var subtitle: String? = nil
     let icon: String
     var iconTint: Color = .white
     let value: Value
@@ -13,7 +14,14 @@ struct NavigationRow<Value: Hashable>: View {
         NavigationLink(value: value) {
             HStack {
                 Glyph(symbol: icon, tint: iconTint)
-                Text(title)
+                VStack(alignment: .leading, spacing: 2) {
+                    Text(title)
+                    if let subtitle {
+                        Text(subtitle)
+                            .font(.caption)
+                            .foregroundStyle(.secondary)
+                    }
+                }
             }
         }
     }

+ 246 - 84
LoopFollow/Settings/SettingsMenuView.swift

@@ -9,100 +9,44 @@ struct SettingsMenuView: View {
 
     var body: some View {
         List {
-            dataSection
-
-            Section("Display Settings") {
-                NavigationRow(title: "General",
-                              icon: "gearshape",
-                              value: SettingsRoute.general)
-                NavigationRow(title: "Graph",
-                              icon: "chart.xyaxis.line",
-                              value: SettingsRoute.graph)
-
-                if !nightscoutURL.value.isEmpty {
-                    NavigationRow(title: "Information Display",
-                                  icon: "info.circle",
-                                  value: SettingsRoute.infoDisplay)
-                }
-
-                NavigationRow(title: "Units and Metrics",
-                              icon: "scalemass",
-                              value: SettingsRoute.units)
-
-                NavigationRow(title: "Tabs",
-                              icon: "rectangle.3.group",
-                              value: SettingsRoute.tabSettings)
-            }
-
-            Section("App Settings") {
-                NavigationRow(title: "Background Refresh",
-                              icon: "arrow.clockwise",
-                              value: SettingsRoute.backgroundRefresh)
-
-                NavigationRow(title: "Import/Export",
-                              icon: "square.and.arrow.down",
-                              value: SettingsRoute.importExport)
-
-                NavigationRow(title: "APN",
-                              icon: "bell.and.waves.left.and.right",
-                              value: SettingsRoute.apn)
-
-                #if !targetEnvironment(macCatalyst)
-                    NavigationRow(title: "Live Activity",
-                                  icon: "dot.radiowaves.left.and.right",
-                                  value: SettingsRoute.liveActivity)
-                #endif
-
-                if !nightscoutURL.value.isEmpty {
-                    NavigationRow(title: "Remote",
-                                  icon: "antenna.radiowaves.left.and.right",
-                                  value: SettingsRoute.remote)
+            ForEach(SettingsRoute.menuSections(nightscoutConfigured: !nightscoutURL.value.isEmpty), id: \.0) { section, routes in
+                Section(section.rawValue) {
+                    ForEach(routes) { route in
+                        NavigationRow(title: route.title,
+                                      icon: route.icon,
+                                      value: route)
+                    }
                 }
             }
-
-            Section("Alarms") {
-                NavigationRow(title: "Alarms",
-                              icon: "bell.badge",
-                              value: SettingsRoute.alarmSettings)
-            }
-
-            Section("Integrations") {
-                NavigationRow(title: "Calendar",
-                              icon: "calendar",
-                              value: SettingsRoute.calendar)
-
-                NavigationRow(title: "Contact",
-                              icon: "person.circle",
-                              value: SettingsRoute.contact)
-            }
-
-            Section("Advanced Settings") {
-                NavigationRow(title: "Advanced",
-                              icon: "exclamationmark.shield",
-                              value: SettingsRoute.advanced)
-            }
         }
         .navigationTitle("Settings")
         .navigationBarTitleDisplayMode(.large)
     }
+}
 
-    // MARK: – Section builders
+// MARK: – Sheet routing
 
-    @ViewBuilder
-    private var dataSection: some View {
-        Section("Data Settings") {
-            NavigationRow(title: "Nightscout",
-                          icon: "network",
-                          value: SettingsRoute.nightscout)
-
-            NavigationRow(title: "Dexcom",
-                          icon: "sensor.tag.radiowaves.forward",
-                          value: SettingsRoute.dexcom)
-        }
-    }
+enum SettingsSection: String, CaseIterable, Hashable {
+    case data = "Data Settings"
+    case display = "Display Settings"
+    case app = "App Settings"
+    case alarms = "Alarms"
+    case integrations = "Integrations"
+    case advanced = "Advanced Settings"
 }
 
-// MARK: – Sheet routing
+/// A single setting inside a settings screen, exposed to Menu search. Leaves
+/// are not navigable on their own — a search hit opens the screen
+/// (`SettingsRoute`) that contains it.
+struct SettingsLeaf: Hashable {
+    let title: String
+    let keywords: [String]
+
+    init(_ title: String, _ keywords: [String] = []) {
+        self.title = title
+        self.keywords = keywords
+    }
+}
 
 enum SettingsRoute: Hashable, Identifiable {
     case settings
@@ -125,6 +69,224 @@ enum SettingsRoute: Hashable, Identifiable {
 
     var id: Self { self }
 
+    // MARK: – Row presentation (single source of truth)
+
+    /// Title shown in the Settings list and used for search matching.
+    /// Non-row cases (`.settings`, `.aggregatedStats`) return "" and are never
+    /// included in `menuSections`.
+    var title: String {
+        switch self {
+        case .nightscout: return "Nightscout"
+        case .dexcom: return "Dexcom"
+        case .general: return "General"
+        case .graph: return "Graph"
+        case .infoDisplay: return "Information Display"
+        case .units: return "Units and Metrics"
+        case .tabSettings: return "Tabs"
+        case .backgroundRefresh: return "Background Refresh"
+        case .importExport: return "Import/Export"
+        case .apn: return "APN"
+        #if !targetEnvironment(macCatalyst)
+            case .liveActivity: return "Live Activity"
+        #endif
+        case .remote: return "Remote"
+        case .alarmSettings: return "Alarms"
+        case .calendar: return "Calendar"
+        case .contact: return "Contact"
+        case .advanced: return "Advanced"
+        case .settings, .aggregatedStats: return ""
+        }
+    }
+
+    var icon: String {
+        switch self {
+        case .nightscout: return "network"
+        case .dexcom: return "sensor.tag.radiowaves.forward"
+        case .general: return "gearshape"
+        case .graph: return "chart.xyaxis.line"
+        case .infoDisplay: return "info.circle"
+        case .units: return "scalemass"
+        case .tabSettings: return "rectangle.3.group"
+        case .backgroundRefresh: return "arrow.clockwise"
+        case .importExport: return "square.and.arrow.down"
+        case .apn: return "bell.and.waves.left.and.right"
+        #if !targetEnvironment(macCatalyst)
+            case .liveActivity: return "dot.radiowaves.left.and.right"
+        #endif
+        case .remote: return "antenna.radiowaves.left.and.right"
+        case .alarmSettings: return "bell.badge"
+        case .calendar: return "calendar"
+        case .contact: return "person.circle"
+        case .advanced: return "exclamationmark.shield"
+        case .settings, .aggregatedStats: return ""
+        }
+    }
+
+    /// Extra synonyms so search finds a page by related terms, not just its title.
+    var keywords: [String] {
+        switch self {
+        case .graph: return ["chart"]
+        case .units: return ["mmol", "mgdl", "metrics"]
+        case .infoDisplay: return ["info"]
+        case .apn: return ["push", "notification"]
+        case .liveActivity: return ["dynamic island", "lock screen"]
+        case .importExport: return ["import", "export", "backup"]
+        default: return []
+        }
+    }
+
+    /// The individual settings inside this screen, so Menu search can find a
+    /// bottom-level setting (e.g. "Graph Basal") and open the screen containing
+    /// it. Titles mirror the row (or section) titles in each screen's view —
+    /// keep them in sync when adding or renaming rows.
+    var leaves: [SettingsLeaf] {
+        switch self {
+        case .nightscout: return [
+                SettingsLeaf("URL"),
+                SettingsLeaf("Access Token", ["token"]),
+                SettingsLeaf("Enable WebSocket", ["websocket", "real-time"]),
+            ]
+        case .dexcom: return [
+                SettingsLeaf("User Name", ["username"]),
+                SettingsLeaf("Password"),
+                SettingsLeaf("Server"),
+            ]
+        case .general: return [
+                SettingsLeaf("Display App Badge", ["badge"]),
+                SettingsLeaf("Persistent Notification"),
+                SettingsLeaf("Appearance", ["dark mode", "light mode", "theme"]),
+                SettingsLeaf("Display Stats"),
+                SettingsLeaf("Display Small Graph"),
+                SettingsLeaf("Color BG Text"),
+                SettingsLeaf("Keep Screen Active", ["screen lock", "screenlock"]),
+                SettingsLeaf("Show Display Name"),
+                SettingsLeaf("Snoozer emoji"),
+                SettingsLeaf("Force portrait mode", ["orientation", "landscape"]),
+                SettingsLeaf("Time Zone Override", ["timezone"]),
+                SettingsLeaf("Speak BG", ["voice", "speech"]),
+                SettingsLeaf("Send anonymous usage stats", ["telemetry", "diagnostics"]),
+            ]
+        case .graph: return [
+                SettingsLeaf("Display Dots"),
+                SettingsLeaf("Display Lines"),
+                SettingsLeaf("Show DIA Lines", ["dia"]),
+                SettingsLeaf("Show −30 min Line", ["-30"]),
+                SettingsLeaf("Show −90 min Line", ["-90"]),
+                SettingsLeaf("Show Yesterday's BG", ["yesterday"]),
+                SettingsLeaf("Show Midnight Lines"),
+                SettingsLeaf("Show Carb/Bolus Values", ["carbs"]),
+                SettingsLeaf("Show Carb Absorption"),
+                SettingsLeaf("Treatments on Small Graph"),
+                SettingsLeaf("Small Graph Height", ["height"]),
+                SettingsLeaf("Hours of Prediction", ["prediction"]),
+                SettingsLeaf("Prediction Style"),
+                SettingsLeaf("Min Basal", ["basal scale"]),
+                SettingsLeaf("Min BG Scale"),
+                SettingsLeaf("Show Days Back", ["history", "days back"]),
+            ]
+        case .infoDisplay:
+            return [SettingsLeaf("Hide Information Table")]
+                + InfoType.allCases.map { SettingsLeaf($0.name) }
+        case .units: return [
+                SettingsLeaf("Glucose Unit"),
+                SettingsLeaf("Range Mode", ["tir", "titr", "time in range"]),
+                SettingsLeaf("Glycemic Metrics", ["hba1c", "ehba1c", "gmi"]),
+                SettingsLeaf("Variability", ["standard deviation", "cv"]),
+            ]
+        case .backgroundRefresh: return [
+                SettingsLeaf("Background Refresh Type", ["silent tune", "bluetooth", "rileylink", "omnipod", "heartbeat"]),
+            ]
+        case .importExport: return [
+                SettingsLeaf("Scan QR Code to Import Settings", ["qr"]),
+                SettingsLeaf("Export Settings To QR Code", ["qr"]),
+            ]
+        case .apn: return [
+                SettingsLeaf("APNS Key ID", ["apns"]),
+                SettingsLeaf("APNS Key", ["apns", "p8"]),
+            ]
+        #if !targetEnvironment(macCatalyst)
+            case .liveActivity: return [
+                    SettingsLeaf("Enable Live Activity"),
+                    SettingsLeaf("Restart Live Activity"),
+                    SettingsLeaf("Grid Slots", ["carplay", "watch"]),
+                ]
+        #endif
+        case .remote: return [
+                SettingsLeaf("Loop Remote Control"),
+                SettingsLeaf("Trio Remote Control", ["trc"]),
+                SettingsLeaf("Meal with Bolus"),
+                SettingsLeaf("Meal with Fat/Protein"),
+                SettingsLeaf("Guardrails", ["max bolus", "max carbs", "max fat", "max protein"]),
+                SettingsLeaf("Bolus Increment"),
+                SettingsLeaf("Shared Secret"),
+                SettingsLeaf("QR Code URL", ["qr"]),
+            ]
+        case .alarmSettings: return [
+                SettingsLeaf("All Alerts Snoozed", ["snooze all"]),
+                SettingsLeaf("All Sounds Muted", ["mute all"]),
+                SettingsLeaf("Day starts", ["schedule", "day/night"]),
+                SettingsLeaf("Night starts", ["schedule", "day/night"]),
+                SettingsLeaf("Override System Volume", ["volume"]),
+                SettingsLeaf("Audio During Calls"),
+                SettingsLeaf("Ignore Zero BG"),
+                SettingsLeaf("Auto-Snooze CGM Start", ["autosnooze"]),
+                SettingsLeaf("Volume Buttons Snooze Alarms"),
+            ]
+        case .calendar: return [
+                SettingsLeaf("Save BG to Calendar", ["watch", "carplay"]),
+                SettingsLeaf("Calendar Text"),
+            ]
+        case .contact: return [
+                SettingsLeaf("Enable Contact BG Updates", ["watch face"]),
+                SettingsLeaf("Background Color"),
+                SettingsLeaf("Color Mode"),
+                SettingsLeaf("Text Color"),
+                SettingsLeaf("Show Trend"),
+                SettingsLeaf("Show Delta"),
+                SettingsLeaf("Show IOB"),
+            ]
+        case .advanced: return [
+                SettingsLeaf("Download Treatments"),
+                SettingsLeaf("Download Prediction"),
+                SettingsLeaf("Graph Basal"),
+                SettingsLeaf("Graph Bolus"),
+                SettingsLeaf("Graph Carbs"),
+                SettingsLeaf("Graph Other Treatments"),
+                SettingsLeaf("BG Update Delay", ["delay"]),
+                SettingsLeaf("Debug Log Level", ["logging"]),
+            ]
+        case .tabSettings, .settings, .aggregatedStats: return []
+        }
+    }
+
+    /// Ordered, grouped list of the routes shown as rows in the Settings menu.
+    /// Encodes the conditional visibility in one place so both the list and the
+    /// Menu search stay in sync.
+    static func menuSections(nightscoutConfigured: Bool) -> [(SettingsSection, [SettingsRoute])] {
+        var display: [SettingsRoute] = [.general, .graph]
+        if nightscoutConfigured {
+            display.append(.infoDisplay)
+        }
+        display += [.units, .tabSettings]
+
+        var app: [SettingsRoute] = [.backgroundRefresh, .importExport, .apn]
+        #if !targetEnvironment(macCatalyst)
+            app.append(.liveActivity)
+        #endif
+        if nightscoutConfigured {
+            app.append(.remote)
+        }
+
+        return [
+            (.data, [.nightscout, .dexcom]),
+            (.display, display),
+            (.app, app),
+            (.alarms, [.alarmSettings]),
+            (.integrations, [.calendar, .contact]),
+            (.advanced, [.advanced]),
+        ]
+    }
+
     @ViewBuilder
     var destination: some View {
         switch self {

+ 239 - 78
LoopFollow/ViewControllers/MoreMenuView.swift

@@ -12,111 +12,239 @@ struct MoreMenuView: View {
     @State private var alertMessage = ""
     @State private var showAlert = false
     @State private var currentVersion: String = AppVersionManager().version()
+    @State private var searchText = ""
+    @ObservedObject private var nightscoutURL = Storage.shared.url
+
+    private var isSearching: Bool {
+        !searchText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
+    }
 
     var body: some View {
         List {
-            // Settings
-            Section {
-                NavigationLink(value: SettingsRoute.settings) {
-                    Label("Settings", systemImage: "gearshape")
+            if isSearching {
+                searchResultsSection
+            } else {
+                menuContent
+            }
+        }
+        .listStyle(.insetGrouped)
+        .navigationTitle("Menu")
+        .navigationBarTitleDisplayMode(.large)
+        .searchable(text: $searchText, prompt: "Search")
+        .overlay {
+            if isSearching, filteredSearchItems.isEmpty {
+                VStack(spacing: 8) {
+                    Image(systemName: "magnifyingglass")
+                        .font(.largeTitle)
+                        .foregroundColor(.secondary)
+                    Text("No Results")
+                        .font(.headline)
+                    Text("No matches for “\(searchText)”.")
+                        .font(.subheadline)
+                        .foregroundColor(.secondary)
+                        .multilineTextAlignment(.center)
                 }
+                .padding(.horizontal)
             }
+        }
+        .task {
+            await fetchVersionInfo()
+        }
+        .alert(alertTitle, isPresented: $showAlert) {
+            Button("OK", role: .cancel) {}
+        } message: {
+            Text(alertMessage)
+        }
+        .navigationDestination(for: SettingsRoute.self) { $0.destination }
+        .navigationDestination(
+            isPresented: Binding(
+                get: { pendingRoute != nil },
+                set: { if !$0 { pendingRoute = nil } }
+            )
+        ) {
+            if let route = pendingRoute {
+                route.destination
+            }
+        }
+    }
 
-            // Features
-            Section("Features") {
-                ForEach(TabItem.featureOrder) { item in
-                    FullRowButton(showsChevron: true) {
-                        let tabs = Storage.shared.orderedTabBarItems()
-                        if let tabIndex = tabs.firstIndex(of: item) {
-                            Observable.shared.selectedTabIndex.value = tabIndex
-                        } else {
-                            pendingRoute = MenuRoute(item)
-                        }
-                    } label: {
-                        Label(item.displayName, systemImage: item.icon)
-                    }
-                }
+    // MARK: - Menu content
+
+    @ViewBuilder
+    private var menuContent: some View {
+        // Settings
+        Section {
+            NavigationLink(value: SettingsRoute.settings) {
+                Label("Settings", systemImage: "gearshape")
             }
+        }
 
-            // Logging
-            Section("Logging") {
-                FullRowButton(showsChevron: true) { pendingRoute = .log } label: {
-                    Label("View Log", systemImage: "doc.text.magnifyingglass")
+        // Features
+        Section("Features") {
+            ForEach(TabItem.featureOrder) { item in
+                FullRowButton(showsChevron: true) {
+                    selectFeature(item)
+                } label: {
+                    Label(item.displayName, systemImage: item.icon)
                 }
+            }
+        }
 
-                FullRowButton { shareLogs() } label: {
-                    Label("Share Logs", systemImage: "square.and.arrow.up")
-                }
+        // Logging
+        Section("Logging") {
+            FullRowButton(showsChevron: true) { pendingRoute = .log } label: {
+                Label("View Log", systemImage: "doc.text.magnifyingglass")
             }
 
-            // Support & Community
-            Section("Support & Community") {
-                Link(destination: URL(string: "https://loopfollowdocs.org/")!) {
-                    HStack {
-                        Label("LoopFollow Docs", systemImage: "book")
-                        Spacer()
-                        Image(systemName: "arrow.up.right.square")
-                            .foregroundStyle(.tertiary)
-                    }
-                }
+            FullRowButton { shareLogs() } label: {
+                Label("Share Logs", systemImage: "square.and.arrow.up")
+            }
+        }
 
-                Link(destination: URL(string: "https://discord.gg/KQgk3gzuYU")!) {
+        // Support & Community
+        Section("Support & Community") {
+            ForEach(MoreMenuView.supportLinks, id: \.url) { link in
+                Link(destination: link.url) {
                     HStack {
-                        Label("Loop and Learn Discord", systemImage: "bubble.left.and.bubble.right")
+                        Label(link.title, systemImage: link.icon)
                         Spacer()
                         Image(systemName: "arrow.up.right.square")
                             .foregroundStyle(.tertiary)
                     }
                 }
+            }
+        }
 
-                Link(destination: URL(string: "https://www.facebook.com/groups/loopfollowlnl")!) {
-                    HStack {
-                        Label("LoopFollow Facebook Group", systemImage: "person.2.fill")
-                        Spacer()
-                        Image(systemName: "arrow.up.right.square")
-                            .foregroundStyle(.tertiary)
-                    }
-                }
+        // Build Information
+        Section("Build Information") {
+            buildInfoRow(title: "Version", value: currentVersion, color: versionTint)
+            buildInfoRow(title: "Latest version", value: latestVersion ?? "Fetching…", color: .secondary)
+
+            let build = BuildDetails.default
+            if !(build.isMacApp() || build.isSimulatorBuild()) {
+                buildInfoRow(
+                    title: build.expirationHeaderString,
+                    value: dateTimeUtils.formattedDate(from: build.calculateExpirationDate()),
+                    color: .secondary
+                )
             }
 
-            // Build Information
-            Section("Build Information") {
-                buildInfoRow(title: "Version", value: currentVersion, color: versionTint)
-                buildInfoRow(title: "Latest version", value: latestVersion ?? "Fetching…", color: .secondary)
-
-                let build = BuildDetails.default
-                if !(build.isMacApp() || build.isSimulatorBuild()) {
-                    buildInfoRow(
-                        title: build.expirationHeaderString,
-                        value: dateTimeUtils.formattedDate(from: build.calculateExpirationDate()),
-                        color: .secondary
-                    )
-                }
+            buildInfoRow(title: "Built", value: dateTimeUtils.formattedDate(from: build.buildDate()), color: .secondary)
+            buildInfoRow(title: "Branch", value: build.branchAndSha, color: .secondary)
+        }
+    }
+
+    private func selectFeature(_ item: TabItem) {
+        let tabs = Storage.shared.orderedTabBarItems()
+        if let tabIndex = tabs.firstIndex(of: item) {
+            Observable.shared.selectedTabIndex.value = tabIndex
+        } else {
+            pendingRoute = MenuRoute(item)
+        }
+    }
 
-                buildInfoRow(title: "Built", value: dateTimeUtils.formattedDate(from: build.buildDate()), color: .secondary)
-                buildInfoRow(title: "Branch", value: build.branchAndSha, color: .secondary)
+    // MARK: - Search
+
+    /// External Support & Community links, shared by the menu and search so their
+    /// titles live in one place.
+    private static let supportLinks: [(title: String, icon: String, url: URL)] = [
+        ("LoopFollow Docs", "book", URL(string: "https://loopfollowdocs.org/")!),
+        ("Loop and Learn Discord", "bubble.left.and.bubble.right", URL(string: "https://discord.gg/KQgk3gzuYU")!),
+        ("LoopFollow Facebook Group", "person.2.fill", URL(string: "https://www.facebook.com/groups/loopfollowlnl")!),
+    ]
+
+    /// The searchable universe, assembled from the same sources that render the
+    /// menu — the Settings routes, the tab features, and the static rows — so it
+    /// never needs to be kept in sync by hand.
+    private var searchItems: [MenuSearchItem] {
+        var items: [MenuSearchItem] = [
+            MenuSearchItem(title: "Settings", icon: "gearshape", keywords: [], kind: .settings(.settings)),
+        ]
+
+        let settingsSections = SettingsRoute.menuSections(nightscoutConfigured: !nightscoutURL.value.isEmpty)
+
+        for (_, routes) in settingsSections {
+            for route in routes {
+                items.append(MenuSearchItem(title: route.title, icon: route.icon, keywords: route.keywords, kind: .settings(route)))
             }
         }
-        .listStyle(.insetGrouped)
-        .navigationTitle("Menu")
-        .navigationBarTitleDisplayMode(.large)
-        .task {
-            await fetchVersionInfo()
+
+        for item in TabItem.featureOrder {
+            items.append(MenuSearchItem(title: item.displayName, icon: item.icon, keywords: [], kind: .feature(item)))
         }
-        .alert(alertTitle, isPresented: $showAlert) {
-            Button("OK", role: .cancel) {}
-        } message: {
-            Text(alertMessage)
+
+        items.append(MenuSearchItem(title: "View Log", icon: "doc.text.magnifyingglass", keywords: ["logging"], kind: .viewLog))
+        items.append(MenuSearchItem(title: "Share Logs", icon: "square.and.arrow.up", keywords: ["logging"], kind: .shareLogs))
+
+        for link in MoreMenuView.supportLinks {
+            items.append(MenuSearchItem(title: link.title, icon: link.icon, keywords: ["support", "community"], kind: .link(link.url)))
         }
-        .navigationDestination(for: SettingsRoute.self) { $0.destination }
-        .navigationDestination(
-            isPresented: Binding(
-                get: { pendingRoute != nil },
-                set: { if !$0 { pendingRoute = nil } }
-            )
-        ) {
-            if let route = pendingRoute {
-                route.destination
+
+        // Bottom-level settings inside each sub-screen. A hit opens the screen
+        // that contains the setting. Appended last so screen- and feature-level
+        // matches rank above individual settings.
+        for (_, routes) in settingsSections {
+            for route in routes {
+                for leaf in route.leaves {
+                    items.append(MenuSearchItem(
+                        title: leaf.title,
+                        icon: route.icon,
+                        keywords: leaf.keywords,
+                        kind: .settings(route),
+                        subtitle: "Settings → \(route.title)"
+                    ))
+                }
+            }
+        }
+
+        return items
+    }
+
+    private var filteredSearchItems: [MenuSearchItem] {
+        searchItems.filter(matches)
+    }
+
+    private func matches(_ item: MenuSearchItem) -> Bool {
+        let query = searchText.trimmingCharacters(in: .whitespacesAndNewlines)
+        guard !query.isEmpty else { return true }
+        return item.title.localizedCaseInsensitiveContains(query)
+            || item.keywords.contains { $0.localizedCaseInsensitiveContains(query) }
+    }
+
+    @ViewBuilder
+    private var searchResultsSection: some View {
+        Section {
+            ForEach(filteredSearchItems) { item in
+                searchRow(for: item)
+            }
+        }
+    }
+
+    @ViewBuilder
+    private func searchRow(for item: MenuSearchItem) -> some View {
+        switch item.kind {
+        case let .settings(route):
+            NavigationRow(title: item.title, subtitle: item.subtitle, icon: item.icon, value: route)
+        case let .feature(feature):
+            FullRowButton(showsChevron: true) { selectFeature(feature) } label: {
+                Label(item.title, systemImage: item.icon)
+            }
+        case .viewLog:
+            FullRowButton(showsChevron: true) { pendingRoute = .log } label: {
+                Label(item.title, systemImage: item.icon)
+            }
+        case .shareLogs:
+            FullRowButton { shareLogs() } label: {
+                Label(item.title, systemImage: item.icon)
+            }
+        case let .link(url):
+            Link(destination: url) {
+                HStack {
+                    Label(item.title, systemImage: item.icon)
+                    Spacer()
+                    Image(systemName: "arrow.up.right.square")
+                        .foregroundStyle(.tertiary)
+                }
             }
         }
     }
@@ -211,6 +339,39 @@ struct MoreMenuView: View {
     }
 }
 
+// MARK: – Search model
+
+/// A single searchable menu entry. Assembled from the menu's existing data
+/// sources (see `MoreMenuView.searchItems`); `kind` carries how tapping it
+/// should behave so results reuse the same navigation as the live rows.
+private struct MenuSearchItem: Identifiable {
+    let title: String
+    let icon: String
+    let keywords: [String]
+    let kind: Kind
+    var subtitle: String? = nil
+
+    enum Kind {
+        case settings(SettingsRoute)
+        case feature(TabItem)
+        case viewLog
+        case shareLogs
+        case link(URL)
+    }
+
+    var id: String {
+        // Leaf settings share their screen's route, so the title is part of
+        // the identity.
+        switch kind {
+        case let .settings(route): return "settings.\(route).\(title)"
+        case let .feature(item): return "feature.\(item.rawValue)"
+        case .viewLog: return "viewLog"
+        case .shareLogs: return "shareLogs"
+        case let .link(url): return "link.\(url.absoluteString)"
+        }
+    }
+}
+
 // MARK: – Full-row tappable button
 
 private struct FullRowButton<Label: View>: View {