CustomCGMOptionsView.swift 22 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486
  1. import LoopKit
  2. import LoopKitUI
  3. import SwiftUI
  4. import Swinject
  5. extension CGMSettings {
  6. struct CustomCGMOptionsView: BaseView {
  7. let resolver: Resolver
  8. @ObservedObject var state: CGMSettings.StateModel
  9. let cgmCurrent: CGMModel
  10. let deleteCGM: () -> Void
  11. @Environment(\.colorScheme) var colorScheme
  12. @Environment(AppState.self) var appState
  13. @Environment(\.presentationMode) var presentationMode
  14. @State private var shouldDisplayDeletionConfirmation: Bool = false
  15. // Simulator settings
  16. @State private var centerValue: Double = UserDefaults.standard.double(forKey: "GlucoseSimulator_CenterValue")
  17. @State private var amplitude: Double = UserDefaults.standard.double(forKey: "GlucoseSimulator_Amplitude")
  18. @State private var period: Double = UserDefaults.standard.double(forKey: "GlucoseSimulator_Period")
  19. @State private var noiseAmplitude: Double = UserDefaults.standard.double(forKey: "GlucoseSimulator_NoiseAmplitude")
  20. @State private var produceStaleValues: Bool = UserDefaults.standard.bool(forKey: "GlucoseSimulator_ProduceStaleValues")
  21. /// Drives the synthetic `cgmStatusHighlight`
  22. @State private var simulatedScenarioRaw: String = UserDefaults.standard
  23. .string(forKey: "GlucoseSimulator.simulatedScenario") ?? SimulatedSensorScenario.runningNormally.rawValue
  24. /// Routes "open URL failed" warnings through `TrioAlertManager` so
  25. /// they share the same in-app banner UI as the rest of the alert
  26. /// pipeline (no more SwiftMessages roundtrip).
  27. private func warnOpenFailed(identifier: String, title: String, body: String) {
  28. let content = Alert.Content(
  29. title: title,
  30. body: body,
  31. acknowledgeActionButtonLabel: String(localized: "OK")
  32. )
  33. let alert = Alert(
  34. identifier: Alert.Identifier(managerIdentifier: "trio.cgmSettings", alertIdentifier: identifier),
  35. foregroundContent: content,
  36. backgroundContent: content,
  37. trigger: .immediate,
  38. interruptionLevel: .active,
  39. sound: nil
  40. )
  41. resolver.resolve(TrioAlertManager.self)?.issueAlert(alert)
  42. }
  43. // Initialize state variables with defaults if needed
  44. private func initializeSimulatorSettings() {
  45. if centerValue == 0 {
  46. centerValue = OscillatingGenerator.Defaults.centerValue
  47. }
  48. if amplitude == 0 {
  49. amplitude = OscillatingGenerator.Defaults.amplitude
  50. }
  51. if period == 0 {
  52. period = OscillatingGenerator.Defaults.period
  53. }
  54. if noiseAmplitude == 0 {
  55. noiseAmplitude = OscillatingGenerator.Defaults.noiseAmplitude
  56. }
  57. // produceStaleValues is already initialized as false by default
  58. }
  59. // Save simulator settings to UserDefaults
  60. private func saveSimulatorSettings() {
  61. UserDefaults.standard.set(centerValue, forKey: "GlucoseSimulator_CenterValue")
  62. UserDefaults.standard.set(amplitude, forKey: "GlucoseSimulator_Amplitude")
  63. UserDefaults.standard.set(period, forKey: "GlucoseSimulator_Period")
  64. UserDefaults.standard.set(noiseAmplitude, forKey: "GlucoseSimulator_NoiseAmplitude")
  65. UserDefaults.standard.set(produceStaleValues, forKey: "GlucoseSimulator_ProduceStaleValues")
  66. }
  67. var body: some View {
  68. NavigationView {
  69. Form {
  70. if cgmCurrent.type != .none {
  71. if cgmCurrent.type == .nightscout {
  72. nightscoutSection
  73. } else if cgmCurrent.type == .xdrip {
  74. xDripConfigurationSection
  75. } else if cgmCurrent.type == .simulator {
  76. simulatorConfigurationSection
  77. }
  78. if let appURL = cgmCurrent.type.appURL {
  79. Section {
  80. Button {
  81. UIApplication.shared.open(appURL, options: [:]) { success in
  82. if !success {
  83. warnOpenFailed(
  84. identifier: "cgm.app.open.failed",
  85. title: String(localized: "Open failed"),
  86. body: String(localized: "Unable to open the app")
  87. )
  88. }
  89. }
  90. }
  91. label: {
  92. Label(
  93. "Open \(cgmCurrent.displayName)",
  94. systemImage: "waveform.path.ecg.rectangle"
  95. ).font(.title3)
  96. .padding() }
  97. .frame(maxWidth: .infinity, alignment: .center)
  98. .buttonStyle(.bordered)
  99. }.listRowBackground(Color.clear)
  100. }
  101. }
  102. }
  103. .navigationTitle(cgmCurrent.displayName)
  104. .navigationBarTitleDisplayMode(.inline)
  105. .toolbar {
  106. /// proper positioning should be .leading
  107. /// LoopKit submodules set placement to .trailing; we'll keep it "proper" here
  108. ToolbarItem(placement: .topBarLeading) {
  109. Button("Close") {
  110. presentationMode.wrappedValue.dismiss()
  111. }
  112. }
  113. }
  114. .safeAreaInset(
  115. edge: .bottom,
  116. spacing: 0
  117. ) {
  118. stickyDeleteButton
  119. }
  120. .scrollContentBackground(.hidden)
  121. .background(appState.trioBackgroundColor(for: colorScheme))
  122. .confirmationDialog("Delete CGM", isPresented: $shouldDisplayDeletionConfirmation) {
  123. Button(role: .destructive) {
  124. deleteCGM()
  125. } label: {
  126. Text("Delete \(cgmCurrent.displayName)")
  127. .font(.headline)
  128. .tint(.red)
  129. }
  130. } message: { Text("Are you sure you want to delete \(cgmCurrent.displayName)?") }
  131. .onAppear {
  132. if cgmCurrent.type == .simulator {
  133. initializeSimulatorSettings()
  134. }
  135. }
  136. }
  137. }
  138. var nightscoutSection: some View {
  139. Group {
  140. Section(
  141. header: Text("Configuration"),
  142. content: {
  143. VStack(alignment: .leading, spacing: 10) {
  144. Text("CGM is not used as heartbeat.").padding(.top)
  145. Text(
  146. state.url == nil ?
  147. "To configure your CGM, tap the button below. In the form that opens, enter your Nightscout credentials to connect to your instance." :
  148. "Tap the button below to open your Nightscout instance in your iPhone's default browser."
  149. ).font(.footnote)
  150. .foregroundStyle(Color.secondary)
  151. .lineLimit(nil)
  152. .padding(.vertical)
  153. }
  154. NavigationLink(
  155. destination: NightscoutConfig.RootView(resolver: resolver, displayClose: false),
  156. label: { Text("Configure Nightscout").foregroundStyle(Color.accentColor) }
  157. )
  158. }
  159. ).listRowBackground(Color.chart)
  160. if let url = state.url {
  161. Section {
  162. Button {
  163. UIApplication.shared.open(url, options: [:]) { success in
  164. if !success {
  165. warnOpenFailed(
  166. identifier: "nightscout.open.failed",
  167. title: String(localized: "Open failed"),
  168. body: String(localized: "No URL available")
  169. )
  170. }
  171. }
  172. }
  173. label: {
  174. Label(
  175. "Open Nightscout",
  176. systemImage: "waveform.path.ecg.rectangle"
  177. ).font(.title3)
  178. .padding() }
  179. .frame(maxWidth: .infinity, alignment: .center)
  180. .buttonStyle(.bordered)
  181. }
  182. .listRowBackground(Color.clear)
  183. }
  184. }
  185. }
  186. var xDripConfigurationSection: some View {
  187. Section(
  188. header: Text("Configuration"),
  189. content: {
  190. VStack(alignment: .leading) {
  191. if let cgmTransmitterDeviceAddress = UserDefaults.standard
  192. .cgmTransmitterDeviceAddress
  193. {
  194. Text("CGM address :").padding(.top)
  195. Text(cgmTransmitterDeviceAddress)
  196. } else {
  197. Text("CGM is not used as heartbeat.").padding(.top)
  198. }
  199. HStack(alignment: .center) {
  200. Text(
  201. "A heartbeat tells Trio to start a loop cycle. This is required for closed loop."
  202. )
  203. .font(.footnote)
  204. .foregroundStyle(Color.secondary)
  205. .lineLimit(nil)
  206. Spacer()
  207. }.padding(.vertical)
  208. if let link = cgmCurrent.type.externalLink {
  209. Button {
  210. UIApplication.shared.open(link, options: [:], completionHandler: nil)
  211. } label: {
  212. HStack {
  213. Text("About this source")
  214. Spacer()
  215. Image(systemName: "chevron.right")
  216. }
  217. }
  218. .frame(maxWidth: .infinity, alignment: .leading)
  219. }
  220. }
  221. }
  222. ).listRowBackground(Color.chart)
  223. }
  224. var simulatorConfigurationSection: some View {
  225. Group {
  226. Section(
  227. header: Text("Configuration"),
  228. content: {
  229. VStack(alignment: .leading, spacing: 12) {
  230. Text("CGM is not used as heartbeat.").lineLimit(nil)
  231. .padding(.top)
  232. Text("Glucose trace WILL NOT be affected by any insulin or carb entries.").lineLimit(nil)
  233. .bold()
  234. }
  235. VStack(alignment: .leading, spacing: 8) {
  236. Text(
  237. "The simulator creates a wave-like pattern that mimics natural glucose fluctuations throughout the day."
  238. ).lineLimit(nil)
  239. Text("Configuration changes will take effect on the next glucose reading.")
  240. .padding(.bottom).lineLimit(nil)
  241. }.foregroundStyle(Color.secondary).font(.footnote)
  242. }
  243. ).listRowBackground(Color.chart)
  244. Section {
  245. VStack(alignment: .leading, spacing: 10) {
  246. Toggle(isOn: $produceStaleValues) {
  247. VStack(alignment: .leading) {
  248. Text("Produce Stale Values")
  249. }
  250. }
  251. .padding(.top)
  252. .onChange(of: produceStaleValues) { _, newValue in
  253. UserDefaults.standard.set(newValue, forKey: "GlucoseSimulator_ProduceStaleValues")
  254. }
  255. Text(
  256. "When stale values are enabled, the simulator will repeatedly output the last generated glucose value."
  257. )
  258. .font(.footnote)
  259. .foregroundStyle(Color.secondary)
  260. .lineLimit(nil)
  261. .padding(.bottom)
  262. }
  263. }.listRowBackground(Color.chart)
  264. Section(
  265. header: Text("Sensor Lifecycle Scenario"),
  266. footer: Text(
  267. "Drives the outer-ring + tag on the home screen's glucose bobble."
  268. )
  269. ) {
  270. Picker("Scenario", selection: $simulatedScenarioRaw) {
  271. ForEach(SimulatedSensorScenario.allCases) { scenario in
  272. Text(scenario.displayName).tag(scenario.rawValue)
  273. }
  274. }
  275. .pickerStyle(.menu)
  276. .onChange(of: simulatedScenarioRaw) { _, newValue in
  277. UserDefaults.standard.set(newValue, forKey: "GlucoseSimulator.simulatedScenario")
  278. // Push the change through the active simulator
  279. // instance so subjects emit immediately.
  280. if let scenario = SimulatedSensorScenario(rawValue: newValue),
  281. let sim = resolver.resolve(FetchGlucoseManager.self)?.glucoseSource as? GlucoseSimulatorSource
  282. {
  283. sim.applySimulatedScenario(scenario)
  284. }
  285. }
  286. if let scenario = SimulatedSensorScenario(rawValue: simulatedScenarioRaw) {
  287. Text(scenario.devNotes)
  288. .font(.footnote)
  289. .foregroundStyle(Color.secondary)
  290. .lineLimit(nil)
  291. }
  292. }.listRowBackground(Color.chart)
  293. if !produceStaleValues {
  294. Section {
  295. VStack(alignment: .leading, spacing: 10) {
  296. HStack {
  297. Text("Center Value:").bold()
  298. Spacer()
  299. Text(state.units == .mgdL ? centerValue.description : centerValue.formattedAsMmolL).bold()
  300. Text(state.units.rawValue).foregroundStyle(Color.secondary)
  301. }.padding(.top)
  302. Slider(value: $centerValue, in: 80 ... 200, step: 1)
  303. .accentColor(.accentColor)
  304. .onChange(of: centerValue) { _, newValue in
  305. UserDefaults.standard.set(newValue, forKey: "GlucoseSimulator_CenterValue")
  306. }
  307. .padding(.vertical)
  308. Text("The average glucose level around which values will oscillate.")
  309. .font(.footnote)
  310. .foregroundStyle(Color.secondary)
  311. .lineLimit(nil)
  312. .padding(.bottom)
  313. }
  314. }.listRowBackground(Color.chart)
  315. Section {
  316. VStack(alignment: .leading, spacing: 10) {
  317. HStack {
  318. Text("Amplitude:").bold()
  319. Spacer()
  320. Text("±")
  321. Text(state.units == .mgdL ? amplitude.description : amplitude.formattedAsMmolL).bold()
  322. Text(state.units.rawValue).foregroundStyle(Color.secondary)
  323. }.padding(.top)
  324. Slider(value: $amplitude, in: 10 ... 100, step: 5)
  325. .accentColor(.accentColor)
  326. .onChange(of: amplitude) { _, newValue in
  327. UserDefaults.standard.set(newValue, forKey: "GlucoseSimulator_Amplitude")
  328. }
  329. .padding(.vertical)
  330. Text(
  331. "Range: \(state.units == .mgdL ? (centerValue - amplitude).description : (centerValue - amplitude).formattedAsMmolL)–\(state.units == .mgdL ? (centerValue + amplitude).description : (centerValue + amplitude).formattedAsMmolL) \(state.units.rawValue)"
  332. )
  333. .bold()
  334. .font(.footnote)
  335. .foregroundStyle(Color.secondary)
  336. .lineLimit(nil)
  337. Text("The maximum deviation from the center value. Higher values create wider swings.")
  338. .font(.footnote)
  339. .foregroundStyle(Color.secondary)
  340. .lineLimit(nil)
  341. .padding(.bottom)
  342. }
  343. }.listRowBackground(Color.chart)
  344. Section {
  345. VStack(alignment: .leading, spacing: 10) {
  346. HStack {
  347. Text("Period:").bold()
  348. Spacer()
  349. Text(Int(period / 3600).description).bold()
  350. Text("hours").foregroundStyle(Color.secondary)
  351. }.padding(.top)
  352. Slider(value: $period, in: 3600 ... 21600, step: 1800)
  353. .accentColor(.accentColor)
  354. .onChange(of: period) { _, newValue in
  355. UserDefaults.standard.set(newValue, forKey: "GlucoseSimulator_Period")
  356. }
  357. .padding(.vertical)
  358. Text("The time it takes to complete one full cycle from high to low and back to high.")
  359. .font(.footnote)
  360. .foregroundStyle(Color.secondary)
  361. .lineLimit(nil)
  362. .padding(.bottom)
  363. }
  364. }.listRowBackground(Color.chart)
  365. Section {
  366. VStack(alignment: .leading, spacing: 10) {
  367. HStack {
  368. Text("Noise:").bold()
  369. Spacer()
  370. Text("±")
  371. Text(state.units == .mgdL ? noiseAmplitude.description : noiseAmplitude.formattedAsMmolL).bold()
  372. Text(state.units.rawValue).foregroundStyle(Color.secondary)
  373. }.padding(.top)
  374. Slider(value: $noiseAmplitude, in: 0 ... 20, step: 1)
  375. .accentColor(.accentColor)
  376. .onChange(of: noiseAmplitude) { _, newValue in
  377. UserDefaults.standard.set(newValue, forKey: "GlucoseSimulator_NoiseAmplitude")
  378. }
  379. .padding(.vertical)
  380. Text("Random variation added to each reading to simulate real-world sensor noise.")
  381. .font(.footnote)
  382. .foregroundStyle(Color.secondary)
  383. .lineLimit(nil)
  384. .padding(.bottom)
  385. }
  386. }.listRowBackground(Color.chart)
  387. }
  388. Section {
  389. Button(action: {
  390. centerValue = OscillatingGenerator.Defaults.centerValue
  391. amplitude = OscillatingGenerator.Defaults.amplitude
  392. period = OscillatingGenerator.Defaults.period
  393. noiseAmplitude = OscillatingGenerator.Defaults.noiseAmplitude
  394. produceStaleValues = OscillatingGenerator.Defaults.produceStaleValues
  395. saveSimulatorSettings()
  396. }, label: {
  397. Text("Reset to Defaults")
  398. })
  399. .frame(maxWidth: .infinity, alignment: .center)
  400. .tint(.white)
  401. }.listRowBackground(Color.accentColor)
  402. }.listSectionSpacing(sectionSpacing)
  403. }
  404. var stickyDeleteButton: some View {
  405. ZStack {
  406. Rectangle()
  407. .frame(width: UIScreen.main.bounds.width, height: 120)
  408. .foregroundStyle(colorScheme == .dark ? Color.bgDarkerDarkBlue : Color.white)
  409. .background(.thinMaterial)
  410. .opacity(0.8)
  411. .clipShape(Rectangle())
  412. .padding(.bottom, -55)
  413. Button(action: {
  414. shouldDisplayDeletionConfirmation.toggle()
  415. }, label: {
  416. Text("Delete CGM")
  417. .frame(maxWidth: .infinity, maxHeight: .infinity)
  418. .padding(10)
  419. })
  420. .frame(width: UIScreen.main.bounds.width * 0.9, height: 40, alignment: .center)
  421. .background(Color(.systemRed))
  422. .tint(.white)
  423. .clipShape(RoundedRectangle(cornerRadius: 8))
  424. .padding(5)
  425. }
  426. }
  427. }
  428. }