SettingsMenuView.swift 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370
  1. // LoopFollow
  2. // SettingsMenuView.swift
  3. import SwiftUI
  4. import UIKit
  5. struct SettingsMenuView: View {
  6. @ObservedObject private var nightscoutURL = Storage.shared.url
  7. var body: some View {
  8. List {
  9. ForEach(SettingsRoute.menuSections(nightscoutConfigured: !nightscoutURL.value.isEmpty), id: \.0) { section, routes in
  10. Section(section.rawValue) {
  11. ForEach(routes) { route in
  12. NavigationRow(title: route.title,
  13. icon: route.icon,
  14. value: route)
  15. }
  16. }
  17. }
  18. }
  19. .navigationTitle("Settings")
  20. .navigationBarTitleDisplayMode(.large)
  21. }
  22. }
  23. // MARK: – Sheet routing
  24. enum SettingsSection: String, CaseIterable, Hashable {
  25. case data = "Data Settings"
  26. case display = "Display Settings"
  27. case app = "App Settings"
  28. case alarms = "Alarms"
  29. case integrations = "Integrations"
  30. case advanced = "Advanced Settings"
  31. }
  32. /// A single setting inside a settings screen, exposed to Menu search. Leaves
  33. /// are not navigable on their own — a search hit opens the screen
  34. /// (`SettingsRoute`) that contains it.
  35. struct SettingsLeaf: Hashable {
  36. let title: String
  37. let keywords: [String]
  38. init(_ title: String, _ keywords: [String] = []) {
  39. self.title = title
  40. self.keywords = keywords
  41. }
  42. }
  43. enum SettingsRoute: Hashable, Identifiable {
  44. case settings
  45. case units
  46. case nightscout, dexcom
  47. case backgroundRefresh
  48. case general, graph
  49. case tabSettings
  50. case infoDisplay
  51. case alarmSettings
  52. case apn
  53. #if !targetEnvironment(macCatalyst)
  54. case liveActivity
  55. #endif
  56. case remote
  57. case importExport
  58. case calendar, contact
  59. case advanced
  60. case aggregatedStats
  61. var id: Self { self }
  62. // MARK: – Row presentation (single source of truth)
  63. /// Title shown in the Settings list and used for search matching.
  64. /// Non-row cases (`.settings`, `.aggregatedStats`) return "" and are never
  65. /// included in `menuSections`.
  66. var title: String {
  67. switch self {
  68. case .nightscout: return "Nightscout"
  69. case .dexcom: return "Dexcom"
  70. case .general: return "General"
  71. case .graph: return "Graph"
  72. case .infoDisplay: return "Information Display"
  73. case .units: return "Units and Metrics"
  74. case .tabSettings: return "Tabs"
  75. case .backgroundRefresh: return "Background Refresh"
  76. case .importExport: return "Import/Export"
  77. case .apn: return "APN"
  78. #if !targetEnvironment(macCatalyst)
  79. case .liveActivity: return "Live Activity"
  80. #endif
  81. case .remote: return "Remote"
  82. case .alarmSettings: return "Alarms"
  83. case .calendar: return "Calendar"
  84. case .contact: return "Contact"
  85. case .advanced: return "Advanced"
  86. case .settings, .aggregatedStats: return ""
  87. }
  88. }
  89. var icon: String {
  90. switch self {
  91. case .nightscout: return "network"
  92. case .dexcom: return "sensor.tag.radiowaves.forward"
  93. case .general: return "gearshape"
  94. case .graph: return "chart.xyaxis.line"
  95. case .infoDisplay: return "info.circle"
  96. case .units: return "scalemass"
  97. case .tabSettings: return "rectangle.3.group"
  98. case .backgroundRefresh: return "arrow.clockwise"
  99. case .importExport: return "square.and.arrow.down"
  100. case .apn: return "bell.and.waves.left.and.right"
  101. #if !targetEnvironment(macCatalyst)
  102. case .liveActivity: return "dot.radiowaves.left.and.right"
  103. #endif
  104. case .remote: return "antenna.radiowaves.left.and.right"
  105. case .alarmSettings: return "bell.badge"
  106. case .calendar: return "calendar"
  107. case .contact: return "person.circle"
  108. case .advanced: return "exclamationmark.shield"
  109. case .settings, .aggregatedStats: return ""
  110. }
  111. }
  112. /// Extra synonyms so search finds a page by related terms, not just its title.
  113. var keywords: [String] {
  114. switch self {
  115. case .graph: return ["chart"]
  116. case .units: return ["mmol", "mgdl", "metrics"]
  117. case .infoDisplay: return ["info"]
  118. case .apn: return ["push", "notification"]
  119. #if !targetEnvironment(macCatalyst)
  120. case .liveActivity: return ["dynamic island", "lock screen"]
  121. #endif
  122. case .importExport: return ["import", "export", "backup"]
  123. default: return []
  124. }
  125. }
  126. /// The individual settings inside this screen, so Menu search can find a
  127. /// bottom-level setting (e.g. "Graph Basal") and open the screen containing
  128. /// it. Titles mirror the row (or section) titles in each screen's view —
  129. /// keep them in sync when adding or renaming rows.
  130. var leaves: [SettingsLeaf] {
  131. switch self {
  132. case .nightscout: return [
  133. SettingsLeaf("URL"),
  134. SettingsLeaf("Access Token", ["token"]),
  135. SettingsLeaf("Enable WebSocket", ["websocket", "real-time"]),
  136. ]
  137. case .dexcom: return [
  138. SettingsLeaf("User Name", ["username"]),
  139. SettingsLeaf("Password"),
  140. SettingsLeaf("Server"),
  141. ]
  142. case .general: return [
  143. SettingsLeaf("Display App Badge", ["badge"]),
  144. SettingsLeaf("Persistent Notification"),
  145. SettingsLeaf("Appearance", ["dark mode", "light mode", "theme"]),
  146. SettingsLeaf("Display Stats"),
  147. SettingsLeaf("Display Small Graph"),
  148. SettingsLeaf("Color BG Text"),
  149. SettingsLeaf("Keep Screen Active", ["screen lock", "screenlock"]),
  150. SettingsLeaf("Show Display Name"),
  151. SettingsLeaf("Snoozer emoji"),
  152. SettingsLeaf("Force portrait mode", ["orientation", "landscape"]),
  153. SettingsLeaf("Time Zone Override", ["timezone"]),
  154. SettingsLeaf("Speak BG", ["voice", "speech"]),
  155. SettingsLeaf("Send anonymous usage stats", ["telemetry", "diagnostics"]),
  156. ]
  157. case .graph: return [
  158. SettingsLeaf("Display Dots"),
  159. SettingsLeaf("Display Lines"),
  160. SettingsLeaf("Show DIA Lines", ["dia"]),
  161. SettingsLeaf("Show −30 min Line", ["-30"]),
  162. SettingsLeaf("Show −90 min Line", ["-90"]),
  163. SettingsLeaf("Show Yesterday's BG", ["yesterday"]),
  164. SettingsLeaf("Show Midnight Lines"),
  165. SettingsLeaf("Show Carb/Bolus Values", ["carbs"]),
  166. SettingsLeaf("Show Carb Absorption"),
  167. SettingsLeaf("Treatments on Small Graph"),
  168. SettingsLeaf("Small Graph Height", ["height"]),
  169. SettingsLeaf("Hours of Prediction", ["prediction"]),
  170. SettingsLeaf("Prediction Style"),
  171. SettingsLeaf("Min Basal", ["basal scale"]),
  172. SettingsLeaf("Min BG Scale"),
  173. SettingsLeaf("Show Days Back", ["history", "days back"]),
  174. ]
  175. case .infoDisplay:
  176. return [SettingsLeaf("Hide Information Table")]
  177. + InfoType.allCases.map { SettingsLeaf($0.name) }
  178. case .units: return [
  179. SettingsLeaf("Glucose Unit"),
  180. SettingsLeaf("Range Mode", ["tir", "titr", "time in range"]),
  181. SettingsLeaf("Glycemic Metrics", ["hba1c", "ehba1c", "gmi"]),
  182. SettingsLeaf("Variability", ["standard deviation", "cv"]),
  183. ]
  184. case .backgroundRefresh: return [
  185. SettingsLeaf("Background Refresh Type", ["silent tune", "bluetooth", "rileylink", "omnipod", "heartbeat"]),
  186. ]
  187. case .importExport: return [
  188. SettingsLeaf("Scan QR Code to Import Settings", ["qr"]),
  189. SettingsLeaf("Export Settings To QR Code", ["qr"]),
  190. ]
  191. case .apn: return [
  192. SettingsLeaf("APNS Key ID", ["apns"]),
  193. SettingsLeaf("APNS Key", ["apns", "p8"]),
  194. ]
  195. #if !targetEnvironment(macCatalyst)
  196. case .liveActivity: return [
  197. SettingsLeaf("Enable Live Activity"),
  198. SettingsLeaf("Restart Live Activity"),
  199. SettingsLeaf("Grid Slots", ["carplay", "watch"]),
  200. ]
  201. #endif
  202. case .remote: return [
  203. SettingsLeaf("Loop Remote Control"),
  204. SettingsLeaf("Trio Remote Control", ["trc"]),
  205. SettingsLeaf("Meal with Bolus"),
  206. SettingsLeaf("Meal with Fat/Protein"),
  207. SettingsLeaf("Guardrails", ["max bolus", "max carbs", "max fat", "max protein"]),
  208. SettingsLeaf("Bolus Increment"),
  209. SettingsLeaf("Shared Secret"),
  210. SettingsLeaf("QR Code URL", ["qr"]),
  211. ]
  212. case .alarmSettings: return [
  213. SettingsLeaf("All Alerts Snoozed", ["snooze all"]),
  214. SettingsLeaf("All Sounds Muted", ["mute all"]),
  215. SettingsLeaf("Day starts", ["schedule", "day/night"]),
  216. SettingsLeaf("Night starts", ["schedule", "day/night"]),
  217. SettingsLeaf("Override System Volume", ["volume"]),
  218. SettingsLeaf("Audio During Calls"),
  219. SettingsLeaf("Ignore Zero BG"),
  220. SettingsLeaf("Auto-Snooze CGM Start", ["autosnooze"]),
  221. SettingsLeaf("Volume Buttons Snooze Alarms"),
  222. ]
  223. case .calendar: return [
  224. SettingsLeaf("Save BG to Calendar", ["watch", "carplay"]),
  225. SettingsLeaf("Calendar Text"),
  226. ]
  227. case .contact: return [
  228. SettingsLeaf("Enable Contact BG Updates", ["watch face"]),
  229. SettingsLeaf("Background Color"),
  230. SettingsLeaf("Color Mode"),
  231. SettingsLeaf("Text Color"),
  232. SettingsLeaf("Show Trend"),
  233. SettingsLeaf("Show Delta"),
  234. SettingsLeaf("Show IOB"),
  235. ]
  236. case .advanced: return [
  237. SettingsLeaf("Download Treatments"),
  238. SettingsLeaf("Download Prediction"),
  239. SettingsLeaf("Graph Basal"),
  240. SettingsLeaf("Graph Bolus"),
  241. SettingsLeaf("Graph Carbs"),
  242. SettingsLeaf("Graph Other Treatments"),
  243. SettingsLeaf("BG Update Delay", ["delay"]),
  244. SettingsLeaf("Debug Log Level", ["logging"]),
  245. ]
  246. case .tabSettings, .settings, .aggregatedStats: return []
  247. }
  248. }
  249. /// Ordered, grouped list of the routes shown as rows in the Settings menu.
  250. /// Encodes the conditional visibility in one place so both the list and the
  251. /// Menu search stay in sync.
  252. static func menuSections(nightscoutConfigured: Bool) -> [(SettingsSection, [SettingsRoute])] {
  253. var display: [SettingsRoute] = [.general, .graph]
  254. if nightscoutConfigured {
  255. display.append(.infoDisplay)
  256. }
  257. display += [.units, .tabSettings]
  258. var app: [SettingsRoute] = [.backgroundRefresh, .importExport, .apn]
  259. #if !targetEnvironment(macCatalyst)
  260. app.append(.liveActivity)
  261. #endif
  262. if nightscoutConfigured {
  263. app.append(.remote)
  264. }
  265. return [
  266. (.data, [.nightscout, .dexcom]),
  267. (.display, display),
  268. (.app, app),
  269. (.alarms, [.alarmSettings]),
  270. (.integrations, [.calendar, .contact]),
  271. (.advanced, [.advanced]),
  272. ]
  273. }
  274. @ViewBuilder
  275. var destination: some View {
  276. switch self {
  277. case .settings: SettingsMenuView()
  278. case .units: UnitsSettingsView()
  279. case .nightscout: NightscoutSettingsView(viewModel: .init())
  280. case .dexcom: DexcomSettingsView(viewModel: .init())
  281. case .backgroundRefresh: BackgroundRefreshSettingsView(viewModel: .init())
  282. case .general: GeneralSettingsView()
  283. case .graph: GraphSettingsView()
  284. case .tabSettings: TabCustomizationModal()
  285. case .infoDisplay: InfoDisplaySettingsView(viewModel: .init())
  286. case .alarmSettings: AlarmSettingsView()
  287. case .apn: APNSettingsView()
  288. #if !targetEnvironment(macCatalyst)
  289. case .liveActivity: LiveActivitySettingsView()
  290. #endif
  291. case .remote: RemoteSettingsView(viewModel: .init())
  292. case .importExport: ImportExportSettingsView()
  293. case .calendar: CalendarSettingsView()
  294. case .contact: ContactSettingsView(viewModel: .init())
  295. case .advanced: AdvancedSettingsView(viewModel: .init())
  296. case .aggregatedStats:
  297. AggregatedStatsViewWrapper()
  298. }
  299. }
  300. }
  301. // Helper view to access MainViewController
  302. struct AggregatedStatsViewWrapper: View {
  303. @State private var mainViewController: MainViewController?
  304. var body: some View {
  305. Group {
  306. if let mainVC = mainViewController {
  307. AggregatedStatsContentView(mainViewController: mainVC)
  308. } else {
  309. Text("Loading stats...")
  310. .onAppear {
  311. mainViewController = getMainViewController()
  312. }
  313. }
  314. }
  315. }
  316. private func getMainViewController() -> MainViewController? {
  317. MainViewController.shared
  318. }
  319. }
  320. // MARK: – UIKit helpers (unchanged)
  321. extension UIApplication {
  322. var topMost: UIViewController? {
  323. // `keyWindow` is deprecated and returns nil on Mac Catalyst / multi-window iPad.
  324. // Walk connected scenes instead and prefer the foreground-active one.
  325. let windowScenes = connectedScenes.compactMap { $0 as? UIWindowScene }
  326. let activeScene = windowScenes.first { $0.activationState == .foregroundActive }
  327. ?? windowScenes.first
  328. let rootVC = activeScene?.windows.first(where: \.isKeyWindow)?.rootViewController
  329. ?? activeScene?.windows.first?.rootViewController
  330. guard var top = rootVC else { return nil }
  331. while let presented = top.presentedViewController {
  332. top = presented
  333. }
  334. return top
  335. }
  336. }
  337. extension UIViewController {
  338. func presentSimpleAlert(title: String, message: String) {
  339. let a = UIAlertController(title: title,
  340. message: message,
  341. preferredStyle: .alert)
  342. a.addAction(UIAlertAction(title: "OK", style: .default))
  343. present(a, animated: true)
  344. }
  345. }