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. case .liveActivity: return ["dynamic island", "lock screen"]
  120. case .importExport: return ["import", "export", "backup"]
  121. default: return []
  122. }
  123. }
  124. /// The individual settings inside this screen, so Menu search can find a
  125. /// bottom-level setting (e.g. "Graph Basal") and open the screen containing
  126. /// it. Titles mirror the row (or section) titles in each screen's view —
  127. /// keep them in sync when adding or renaming rows.
  128. var leaves: [SettingsLeaf] {
  129. switch self {
  130. case .nightscout: return [
  131. SettingsLeaf("URL"),
  132. SettingsLeaf("Access Token", ["token"]),
  133. SettingsLeaf("Enable WebSocket", ["websocket", "real-time"]),
  134. ]
  135. case .dexcom: return [
  136. SettingsLeaf("User Name", ["username"]),
  137. SettingsLeaf("Password"),
  138. SettingsLeaf("Server"),
  139. ]
  140. case .general: return [
  141. SettingsLeaf("Display App Badge", ["badge"]),
  142. SettingsLeaf("Persistent Notification"),
  143. SettingsLeaf("Appearance", ["dark mode", "light mode", "theme"]),
  144. SettingsLeaf("Display Stats"),
  145. SettingsLeaf("Display Small Graph"),
  146. SettingsLeaf("Color BG Text"),
  147. SettingsLeaf("Keep Screen Active", ["screen lock", "screenlock"]),
  148. SettingsLeaf("Show Display Name"),
  149. SettingsLeaf("Snoozer emoji"),
  150. SettingsLeaf("Force portrait mode", ["orientation", "landscape"]),
  151. SettingsLeaf("Time Zone Override", ["timezone"]),
  152. SettingsLeaf("Speak BG", ["voice", "speech"]),
  153. SettingsLeaf("Send anonymous usage stats", ["telemetry", "diagnostics"]),
  154. ]
  155. case .graph: return [
  156. SettingsLeaf("Display Dots"),
  157. SettingsLeaf("Display Lines"),
  158. SettingsLeaf("Show DIA Lines", ["dia"]),
  159. SettingsLeaf("Show −30 min Line", ["-30"]),
  160. SettingsLeaf("Show −90 min Line", ["-90"]),
  161. SettingsLeaf("Show Yesterday's BG", ["yesterday"]),
  162. SettingsLeaf("Show Midnight Lines"),
  163. SettingsLeaf("Show Carb/Bolus Values", ["carbs"]),
  164. SettingsLeaf("Show Carb Absorption"),
  165. SettingsLeaf("Treatments on Small Graph"),
  166. SettingsLeaf("Small Graph Height", ["height"]),
  167. SettingsLeaf("Hours of Prediction", ["prediction"]),
  168. SettingsLeaf("Prediction Style"),
  169. SettingsLeaf("Min Basal", ["basal scale"]),
  170. SettingsLeaf("Min BG Scale"),
  171. SettingsLeaf("Show Days Back", ["history", "days back"]),
  172. ]
  173. case .infoDisplay:
  174. return [SettingsLeaf("Hide Information Table")]
  175. + InfoType.allCases.map { SettingsLeaf($0.name) }
  176. case .units: return [
  177. SettingsLeaf("Glucose Unit"),
  178. SettingsLeaf("Range Mode", ["tir", "titr", "time in range"]),
  179. SettingsLeaf("Glycemic Metrics", ["hba1c", "ehba1c", "gmi"]),
  180. SettingsLeaf("Variability", ["standard deviation", "cv"]),
  181. ]
  182. case .backgroundRefresh: return [
  183. SettingsLeaf("Background Refresh Type", ["silent tune", "bluetooth", "rileylink", "omnipod", "heartbeat"]),
  184. ]
  185. case .importExport: return [
  186. SettingsLeaf("Scan QR Code to Import Settings", ["qr"]),
  187. SettingsLeaf("Export Settings To QR Code", ["qr"]),
  188. ]
  189. case .apn: return [
  190. SettingsLeaf("APNS Key ID", ["apns"]),
  191. SettingsLeaf("APNS Key", ["apns", "p8"]),
  192. ]
  193. #if !targetEnvironment(macCatalyst)
  194. case .liveActivity: return [
  195. SettingsLeaf("Enable Live Activity"),
  196. SettingsLeaf("Restart Live Activity"),
  197. SettingsLeaf("Grid Slots", ["carplay", "watch"]),
  198. ]
  199. #endif
  200. case .remote: return [
  201. SettingsLeaf("Loop Remote Control"),
  202. SettingsLeaf("Trio Remote Control", ["trc"]),
  203. SettingsLeaf("Meal with Bolus"),
  204. SettingsLeaf("Meal with Fat/Protein"),
  205. SettingsLeaf("Guardrails", ["max bolus", "max carbs", "max fat", "max protein"]),
  206. SettingsLeaf("Bolus Increment"),
  207. SettingsLeaf("Shared Secret"),
  208. SettingsLeaf("QR Code URL", ["qr"]),
  209. ]
  210. case .alarmSettings: return [
  211. SettingsLeaf("All Alerts Snoozed", ["snooze all"]),
  212. SettingsLeaf("All Sounds Muted", ["mute all"]),
  213. SettingsLeaf("Day starts", ["schedule", "day/night"]),
  214. SettingsLeaf("Night starts", ["schedule", "day/night"]),
  215. SettingsLeaf("Override System Volume", ["volume"]),
  216. SettingsLeaf("Audio During Calls"),
  217. SettingsLeaf("Ignore Zero BG"),
  218. SettingsLeaf("Auto-Snooze CGM Start", ["autosnooze"]),
  219. SettingsLeaf("Volume Buttons Snooze Alarms"),
  220. ]
  221. case .calendar: return [
  222. SettingsLeaf("Save BG to Calendar", ["watch", "carplay"]),
  223. SettingsLeaf("Calendar Text"),
  224. ]
  225. case .contact: return [
  226. SettingsLeaf("Enable Contact BG Updates", ["watch face"]),
  227. SettingsLeaf("Background Color"),
  228. SettingsLeaf("Color Mode"),
  229. SettingsLeaf("Text Color"),
  230. SettingsLeaf("Show Trend"),
  231. SettingsLeaf("Show Delta"),
  232. SettingsLeaf("Show IOB"),
  233. ]
  234. case .advanced: return [
  235. SettingsLeaf("Download Treatments"),
  236. SettingsLeaf("Download Prediction"),
  237. SettingsLeaf("Graph Basal"),
  238. SettingsLeaf("Graph Bolus"),
  239. SettingsLeaf("Graph Carbs"),
  240. SettingsLeaf("Graph Other Treatments"),
  241. SettingsLeaf("BG Update Delay", ["delay"]),
  242. SettingsLeaf("Debug Log Level", ["logging"]),
  243. ]
  244. case .tabSettings, .settings, .aggregatedStats: return []
  245. }
  246. }
  247. /// Ordered, grouped list of the routes shown as rows in the Settings menu.
  248. /// Encodes the conditional visibility in one place so both the list and the
  249. /// Menu search stay in sync.
  250. static func menuSections(nightscoutConfigured: Bool) -> [(SettingsSection, [SettingsRoute])] {
  251. var display: [SettingsRoute] = [.general, .graph]
  252. if nightscoutConfigured {
  253. display.append(.infoDisplay)
  254. }
  255. display += [.units, .tabSettings]
  256. var app: [SettingsRoute] = [.backgroundRefresh, .importExport, .apn]
  257. #if !targetEnvironment(macCatalyst)
  258. app.append(.liveActivity)
  259. #endif
  260. if nightscoutConfigured {
  261. app.append(.remote)
  262. }
  263. return [
  264. (.data, [.nightscout, .dexcom]),
  265. (.display, display),
  266. (.app, app),
  267. (.alarms, [.alarmSettings]),
  268. (.integrations, [.calendar, .contact]),
  269. (.advanced, [.advanced]),
  270. ]
  271. }
  272. @ViewBuilder
  273. var destination: some View {
  274. switch self {
  275. case .settings: SettingsMenuView()
  276. case .units: UnitsSettingsView()
  277. case .nightscout: NightscoutSettingsView(viewModel: .init())
  278. case .dexcom: DexcomSettingsView(viewModel: .init())
  279. case .backgroundRefresh: BackgroundRefreshSettingsView(viewModel: .init())
  280. case .general: GeneralSettingsView()
  281. case .graph: GraphSettingsView()
  282. case .tabSettings: TabCustomizationModal()
  283. case .infoDisplay: InfoDisplaySettingsView(viewModel: .init())
  284. case .alarmSettings: AlarmSettingsView()
  285. case .apn: APNSettingsView()
  286. #if !targetEnvironment(macCatalyst)
  287. case .liveActivity: LiveActivitySettingsView()
  288. #endif
  289. case .remote: RemoteSettingsView(viewModel: .init())
  290. case .importExport: ImportExportSettingsView()
  291. case .calendar: CalendarSettingsView()
  292. case .contact: ContactSettingsView(viewModel: .init())
  293. case .advanced: AdvancedSettingsView(viewModel: .init())
  294. case .aggregatedStats:
  295. AggregatedStatsViewWrapper()
  296. }
  297. }
  298. }
  299. // Helper view to access MainViewController
  300. struct AggregatedStatsViewWrapper: View {
  301. @State private var mainViewController: MainViewController?
  302. var body: some View {
  303. Group {
  304. if let mainVC = mainViewController {
  305. AggregatedStatsContentView(mainViewController: mainVC)
  306. } else {
  307. Text("Loading stats...")
  308. .onAppear {
  309. mainViewController = getMainViewController()
  310. }
  311. }
  312. }
  313. }
  314. private func getMainViewController() -> MainViewController? {
  315. MainViewController.shared
  316. }
  317. }
  318. // MARK: – UIKit helpers (unchanged)
  319. import UIKit
  320. extension UIApplication {
  321. var topMost: UIViewController? {
  322. // `keyWindow` is deprecated and returns nil on Mac Catalyst / multi-window iPad.
  323. // Walk connected scenes instead and prefer the foreground-active one.
  324. let windowScenes = connectedScenes.compactMap { $0 as? UIWindowScene }
  325. let activeScene = windowScenes.first { $0.activationState == .foregroundActive }
  326. ?? windowScenes.first
  327. let rootVC = activeScene?.windows.first(where: \.isKeyWindow)?.rootViewController
  328. ?? activeScene?.windows.first?.rootViewController
  329. guard var top = rootVC else { return nil }
  330. while let presented = top.presentedViewController {
  331. top = presented
  332. }
  333. return top
  334. }
  335. }
  336. extension UIViewController {
  337. func presentSimpleAlert(title: String, message: String) {
  338. let a = UIAlertController(title: title,
  339. message: message,
  340. preferredStyle: .alert)
  341. a.addAction(UIAlertAction(title: "OK", style: .default))
  342. present(a, animated: true)
  343. }
  344. }