CustomCGMOptionsView.swift 20 KB

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