From 0dd68176fd4fffce6fd1faa9e3677010e5d18df7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jonas=20Bj=C3=B6rkert?= Date: Wed, 8 Jul 2026 08:34:12 +0200 Subject: [PATCH 1/2] Add search to the Menu covering Settings sub-items MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- LoopFollow/Settings/SettingsMenuView.swift | 195 +++++++----- LoopFollow/ViewControllers/MoreMenuView.swift | 295 +++++++++++++----- 2 files changed, 327 insertions(+), 163 deletions(-) diff --git a/LoopFollow/Settings/SettingsMenuView.swift b/LoopFollow/Settings/SettingsMenuView.swift index f354c51b6..3f1c33807 100644 --- a/LoopFollow/Settings/SettingsMenuView.swift +++ b/LoopFollow/Settings/SettingsMenuView.swift @@ -9,101 +9,32 @@ 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 - - @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) - } - } } // MARK: – Sheet routing +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" +} + enum SettingsRoute: Hashable, Identifiable { case settings case units @@ -125,6 +56,100 @@ 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 [] + } + } + + /// 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 { diff --git a/LoopFollow/ViewControllers/MoreMenuView.swift b/LoopFollow/ViewControllers/MoreMenuView.swift index ac389a122..19db5eda5 100644 --- a/LoopFollow/ViewControllers/MoreMenuView.swift +++ b/LoopFollow/ViewControllers/MoreMenuView.swift @@ -12,111 +12,220 @@ 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) + } + } - 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) + } + } + + // 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)), + ] + + for (_, routes) in SettingsRoute.menuSections(nightscoutConfigured: !nightscoutURL.value.isEmpty) { + 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 + + 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, 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 +320,36 @@ 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 + + enum Kind { + case settings(SettingsRoute) + case feature(TabItem) + case viewLog + case shareLogs + case link(URL) + } + + var id: String { + switch kind { + case let .settings(route): return "settings.\(route)" + 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: View { From 75f3421e0e8b941ff992d9e388ba04eb32be93eb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jonas=20Bj=C3=B6rkert?= Date: Wed, 8 Jul 2026 18:07:34 +0200 Subject: [PATCH 2/2] Make bottom-level settings findable in Menu search MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 → 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. --- LoopFollow/Helpers/Views/NavigationRow.swift | 10 +- LoopFollow/Settings/SettingsMenuView.swift | 137 ++++++++++++++++++ LoopFollow/ViewControllers/MoreMenuView.swift | 28 +++- 3 files changed, 171 insertions(+), 4 deletions(-) diff --git a/LoopFollow/Helpers/Views/NavigationRow.swift b/LoopFollow/Helpers/Views/NavigationRow.swift index 2198a83e2..5c98e929c 100644 --- a/LoopFollow/Helpers/Views/NavigationRow.swift +++ b/LoopFollow/Helpers/Views/NavigationRow.swift @@ -5,6 +5,7 @@ import SwiftUI struct NavigationRow: View { let title: String + var subtitle: String? = nil let icon: String var iconTint: Color = .white let value: Value @@ -13,7 +14,14 @@ struct NavigationRow: 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) + } + } } } } diff --git a/LoopFollow/Settings/SettingsMenuView.swift b/LoopFollow/Settings/SettingsMenuView.swift index 3f1c33807..49f088db6 100644 --- a/LoopFollow/Settings/SettingsMenuView.swift +++ b/LoopFollow/Settings/SettingsMenuView.swift @@ -35,6 +35,19 @@ enum SettingsSection: String, CaseIterable, Hashable { case advanced = "Advanced Settings" } +/// 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 case units @@ -122,6 +135,130 @@ enum SettingsRoute: Hashable, Identifiable { } } + /// 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. diff --git a/LoopFollow/ViewControllers/MoreMenuView.swift b/LoopFollow/ViewControllers/MoreMenuView.swift index 19db5eda5..31f3b3915 100644 --- a/LoopFollow/ViewControllers/MoreMenuView.swift +++ b/LoopFollow/ViewControllers/MoreMenuView.swift @@ -161,7 +161,9 @@ struct MoreMenuView: View { MenuSearchItem(title: "Settings", icon: "gearshape", keywords: [], kind: .settings(.settings)), ] - for (_, routes) in SettingsRoute.menuSections(nightscoutConfigured: !nightscoutURL.value.isEmpty) { + 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))) } @@ -178,6 +180,23 @@ struct MoreMenuView: View { items.append(MenuSearchItem(title: link.title, icon: link.icon, keywords: ["support", "community"], kind: .link(link.url))) } + // 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 } @@ -205,7 +224,7 @@ struct MoreMenuView: View { private func searchRow(for item: MenuSearchItem) -> some View { switch item.kind { case let .settings(route): - NavigationRow(title: item.title, icon: item.icon, value: 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) @@ -330,6 +349,7 @@ private struct MenuSearchItem: Identifiable { let icon: String let keywords: [String] let kind: Kind + var subtitle: String? = nil enum Kind { case settings(SettingsRoute) @@ -340,8 +360,10 @@ private struct MenuSearchItem: Identifiable { } 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)" + case let .settings(route): return "settings.\(route).\(title)" case let .feature(item): return "feature.\(item.rawValue)" case .viewLog: return "viewLog" case .shareLogs: return "shareLogs"