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 f354c51b6..49f088db6 100644 --- a/LoopFollow/Settings/SettingsMenuView.swift +++ b/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 { diff --git a/LoopFollow/ViewControllers/MoreMenuView.swift b/LoopFollow/ViewControllers/MoreMenuView.swift index ac389a122..31f3b3915 100644 --- a/LoopFollow/ViewControllers/MoreMenuView.swift +++ b/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: View {