OverrideRootView.swift 23 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521
  1. import CoreData
  2. import SwiftUI
  3. import Swinject
  4. extension OverrideConfig {
  5. struct RootView: BaseView {
  6. let resolver: Resolver
  7. @StateObject var state = StateModel()
  8. @State private var isEditing = false
  9. @State private var showOverrideCreationSheet = false
  10. @State private var showingDetail = false
  11. @State private var showCheckmark: Bool = false
  12. @State private var selectedPresetID: String?
  13. @State private var selectedOverride: OverrideStored?
  14. // temp targets
  15. @State private var isPromptPresented = false
  16. @State private var isRemoveAlertPresented = false
  17. @State private var removeAlert: Alert?
  18. @State private var isEditingTT = false
  19. @Environment(\.managedObjectContext) var moc
  20. @Environment(\.colorScheme) var colorScheme
  21. var color: LinearGradient {
  22. colorScheme == .dark ? LinearGradient(
  23. gradient: Gradient(colors: [
  24. Color.bgDarkBlue,
  25. Color.bgDarkerDarkBlue
  26. ]),
  27. startPoint: .top,
  28. endPoint: .bottom
  29. )
  30. :
  31. LinearGradient(
  32. gradient: Gradient(colors: [Color.gray.opacity(0.1)]),
  33. startPoint: .top,
  34. endPoint: .bottom
  35. )
  36. }
  37. @FetchRequest(
  38. entity: TempTargetsSlider.entity(),
  39. sortDescriptors: [NSSortDescriptor(key: "date", ascending: false)]
  40. ) var isEnabledArray: FetchedResults<TempTargetsSlider>
  41. private var formatter: NumberFormatter {
  42. let formatter = NumberFormatter()
  43. formatter.numberStyle = .decimal
  44. formatter.maximumFractionDigits = 0
  45. return formatter
  46. }
  47. var body: some View {
  48. VStack {
  49. Picker("Tab", selection: $state.selectedTab) {
  50. ForEach(Tab.allCases) { tab in
  51. Text(NSLocalizedString(tab.name, comment: "")).tag(tab)
  52. }
  53. }
  54. .pickerStyle(.segmented).padding(.horizontal, 10)
  55. Form {
  56. switch state.selectedTab {
  57. case .overrides: overrides()
  58. case .tempTargets: tempTargets() }
  59. }.scrollContentBackground(.hidden).background(color)
  60. .onAppear(perform: configureView)
  61. .navigationBarTitle("Adjustments")
  62. .navigationBarTitleDisplayMode(.large)
  63. .toolbar {
  64. ToolbarItem(placement: .topBarTrailing) {
  65. switch state.selectedTab {
  66. case .overrides:
  67. Button(action: {
  68. showOverrideCreationSheet = true
  69. }, label: {
  70. HStack {
  71. Text("Add Override")
  72. Image(systemName: "plus")
  73. }
  74. })
  75. default:
  76. EmptyView()
  77. }
  78. }
  79. }
  80. .sheet(isPresented: $state.showOverrideEditSheet, onDismiss: {
  81. Task {
  82. await state.resetStateVariables()
  83. state.showOverrideEditSheet = false
  84. }
  85. }) {
  86. if let override = selectedOverride {
  87. EditOverrideForm(overrideToEdit: override, state: state)
  88. }
  89. }
  90. .sheet(isPresented: $showOverrideCreationSheet, onDismiss: {
  91. Task {
  92. await state.resetStateVariables()
  93. showOverrideCreationSheet = false
  94. }
  95. }) {
  96. AddOverrideForm(state: state)
  97. }
  98. }.background(color)
  99. }
  100. @ViewBuilder func overrides() -> some View {
  101. if state.overridePresets.isNotEmpty {
  102. overridePresets
  103. } else {
  104. defaultText
  105. }
  106. if state.isEnabled, state.activeOverrideName.isNotEmpty {
  107. currentActiveOverride
  108. }
  109. if state.overridePresets.isNotEmpty || state.currentActiveOverride != nil {
  110. cancelOverrideButton
  111. }
  112. }
  113. private var defaultText: some View {
  114. Section {} header: {
  115. Text("Add Preset or Override by tapping 'Add Override +' in the top right-hand corner of the screen.")
  116. .textCase(nil)
  117. .foregroundStyle(.secondary)
  118. }
  119. }
  120. private var overridePresets: some View {
  121. Section {
  122. ForEach(state.overridePresets) { preset in
  123. overridesView(for: preset)
  124. .swipeActions(edge: .trailing, allowsFullSwipe: true) {
  125. Button(role: .none) {
  126. Task {
  127. await state.invokeOverridePresetDeletion(preset.objectID)
  128. }
  129. } label: {
  130. Label("Delete", systemImage: "trash")
  131. .tint(.red)
  132. }
  133. Button(action: {
  134. // Set the selected Override to the chosen Preset and pass it to the Edit Sheet
  135. selectedOverride = preset
  136. state.showOverrideEditSheet = true
  137. }, label: {
  138. Label("Edit", systemImage: "pencil")
  139. .tint(.blue)
  140. })
  141. }
  142. }
  143. .onMove(perform: state.reorderOverride)
  144. .listRowBackground(Color.chart)
  145. } header: {
  146. Text("Presets")
  147. } footer: {
  148. HStack {
  149. Image(systemName: "hand.draw.fill")
  150. Text("Swipe left to edit or delete an override preset. Hold, drag and drop to reorder a preset.")
  151. }
  152. }
  153. }
  154. private var currentActiveOverride: some View {
  155. Section {
  156. HStack {
  157. Text("\(state.activeOverrideName) is running")
  158. Spacer()
  159. Image(systemName: "square.and.pencil")
  160. .foregroundStyle(Color.blue)
  161. }
  162. .contentShape(Rectangle())
  163. .onTapGesture {
  164. Task {
  165. /// To avoid editing the Preset when a Preset-Override is running we first duplicate the Preset-Override as a non-Preset Override
  166. /// The currentActiveOverride variable in the State will update automatically via MOC notification
  167. await state.duplicateOverridePresetAndCancelPreviousOverride()
  168. /// selectedOverride is used for passing the chosen Override to the EditSheet so we have to set the updated currentActiveOverride to be the selectedOverride
  169. selectedOverride = state.currentActiveOverride
  170. /// Now we can show the Edit sheet
  171. state.showOverrideEditSheet = true
  172. }
  173. }
  174. }
  175. .listRowBackground(Color.blue.opacity(0.2))
  176. }
  177. private var cancelOverrideButton: some View {
  178. Button(action: {
  179. Task {
  180. // Save cancelled Override in OverrideRunStored Entity
  181. // Cancel ALL active Override
  182. await state.disableAllActiveOverrides(createOverrideRunEntry: true)
  183. }
  184. }, label: {
  185. Text("Cancel Override")
  186. })
  187. .frame(maxWidth: .infinity, alignment: .center)
  188. .disabled(!state.isEnabled)
  189. .listRowBackground(!state.isEnabled ? Color(.systemGray4) : Color(.systemRed))
  190. .tint(.white)
  191. }
  192. @ViewBuilder func tempTargets() -> some View {
  193. if !state.presetsTT.isEmpty {
  194. Section(header: Text("Presets")) {
  195. ForEach(state.presetsTT) { preset in
  196. presetView(for: preset)
  197. }
  198. }.listRowBackground(Color.chart)
  199. }
  200. HStack {
  201. Text("Experimental")
  202. Toggle(isOn: $state.viewPercantage) {}.controlSize(.mini)
  203. Image(systemName: "figure.highintensity.intervaltraining")
  204. Image(systemName: "fork.knife")
  205. }.listRowBackground(Color.chart)
  206. if state.viewPercantage {
  207. Section {
  208. VStack {
  209. Text("\(state.percentageTT.formatted(.number)) % Insulin")
  210. .foregroundColor(isEditingTT ? .orange : .blue)
  211. .font(.largeTitle)
  212. .padding(.vertical)
  213. Slider(
  214. value: $state.percentageTT,
  215. in: 15 ...
  216. min(Double(state.maxValue * 100), 200),
  217. step: 1,
  218. onEditingChanged: { editing in
  219. isEditingTT = editing
  220. }
  221. )
  222. // Only display target slider when not 100 %
  223. if state.percentageTT != 100 {
  224. Spacer()
  225. Divider()
  226. Text(
  227. (
  228. state
  229. .units == .mmolL ?
  230. "\(state.computeTarget().asMmolL.formatted(.number.grouping(.never).rounded().precision(.fractionLength(1)))) mmol/L" :
  231. "\(state.computeTarget().formatted(.number.grouping(.never).rounded().precision(.fractionLength(0)))) mg/dl"
  232. )
  233. + NSLocalizedString(" Target Glucose", comment: "")
  234. )
  235. .foregroundColor(.green)
  236. .padding(.vertical)
  237. Slider(
  238. value: $state.hbt,
  239. in: 101 ... 295,
  240. step: 1
  241. ).accentColor(.green)
  242. }
  243. }
  244. }.listRowBackground(Color.chart)
  245. } else {
  246. Section(header: Text("Custom")) {
  247. HStack {
  248. Text("Target")
  249. Spacer()
  250. TextFieldWithToolBar(text: $state.low, placeholder: "0", numberFormatter: formatter)
  251. Text(state.units.rawValue).foregroundColor(.secondary)
  252. }
  253. HStack {
  254. Text("Duration")
  255. Spacer()
  256. TextFieldWithToolBar(text: $state.durationTT, placeholder: "0", numberFormatter: formatter)
  257. Text("minutes").foregroundColor(.secondary)
  258. }
  259. DatePicker("Date", selection: $state.date)
  260. HStack {
  261. Button { state.enact() }
  262. label: { Text("Enact") }
  263. .disabled(state.durationTT == 0)
  264. .buttonStyle(BorderlessButtonStyle())
  265. .font(.callout)
  266. .controlSize(.mini)
  267. Button { isPromptPresented = true }
  268. label: { Text("Save as preset") }
  269. .disabled(state.durationTT == 0)
  270. .tint(.orange)
  271. .frame(maxWidth: .infinity, alignment: .trailing)
  272. .buttonStyle(BorderlessButtonStyle())
  273. .controlSize(.mini)
  274. }
  275. }.listRowBackground(Color.chart)
  276. }
  277. if state.viewPercantage {
  278. Section {
  279. HStack {
  280. Text("Duration")
  281. Spacer()
  282. TextFieldWithToolBar(text: $state.durationTT, placeholder: "0", numberFormatter: formatter)
  283. Text("minutes").foregroundColor(.secondary)
  284. }
  285. DatePicker("Date", selection: $state.date)
  286. HStack {
  287. Button { state.enact() }
  288. label: { Text("Enact") }
  289. .disabled(state.durationTT == 0)
  290. .buttonStyle(BorderlessButtonStyle())
  291. .font(.callout)
  292. .controlSize(.mini)
  293. Button { isPromptPresented = true }
  294. label: { Text("Save as preset") }
  295. .disabled(state.durationTT == 0)
  296. .tint(.orange)
  297. .frame(maxWidth: .infinity, alignment: .trailing)
  298. .buttonStyle(BorderlessButtonStyle())
  299. .controlSize(.mini)
  300. }
  301. }.listRowBackground(Color.chart)
  302. }
  303. Section {
  304. Button { state.cancel() }
  305. label: {
  306. HStack {
  307. Spacer()
  308. Text("Cancel Temp Target")
  309. Spacer()
  310. Image(systemName: "xmark.app")
  311. .font(.title)
  312. }
  313. }
  314. .frame(maxWidth: .infinity, alignment: .center)
  315. .disabled(state.storage.current() == nil)
  316. .listRowBackground(state.storage.current() == nil ? Color(.systemGray4) : Color(.systemRed))
  317. .tint(.white)
  318. }.popover(isPresented: $isPromptPresented) {
  319. Form {
  320. Section(header: Text("Enter preset name")) {
  321. TextField("Name", text: $state.newPresetName)
  322. Button {
  323. state.save()
  324. isPromptPresented = false
  325. }
  326. label: { Text("Save") }
  327. Button { isPromptPresented = false }
  328. label: { Text("Cancel") }
  329. }
  330. }
  331. }
  332. .onAppear {
  333. configureView()
  334. state.hbt = isEnabledArray.first?.hbt ?? 160
  335. }
  336. }
  337. private func presetView(for preset: TempTarget) -> some View {
  338. var low = preset.targetBottom
  339. var high = preset.targetTop
  340. if state.units == .mmolL {
  341. low = low?.asMmolL
  342. high = high?.asMmolL
  343. }
  344. let isSelected = preset.id == selectedPresetID
  345. return ZStack(alignment: .trailing, content: {
  346. HStack {
  347. VStack {
  348. HStack {
  349. Text(preset.displayName)
  350. Spacer()
  351. }
  352. HStack(spacing: 2) {
  353. Text(
  354. "\(formatter.string(from: (low ?? 0) as NSNumber)!) - \(formatter.string(from: (high ?? 0) as NSNumber)!)"
  355. )
  356. .foregroundColor(.secondary)
  357. .font(.caption)
  358. Text(state.units.rawValue)
  359. .foregroundColor(.secondary)
  360. .font(.caption)
  361. Text("for")
  362. .foregroundColor(.secondary)
  363. .font(.caption)
  364. Text("\(formatter.string(from: preset.duration as NSNumber)!)")
  365. .foregroundColor(.secondary)
  366. .font(.caption)
  367. Text("min")
  368. .foregroundColor(.secondary)
  369. .font(.caption)
  370. Spacer()
  371. }.padding(.top, 2)
  372. }
  373. .contentShape(Rectangle())
  374. .onTapGesture {
  375. state.enactPreset(id: preset.id)
  376. selectedPresetID = preset.id
  377. showCheckmark.toggle()
  378. // deactivate showCheckmark after 3 seconds
  379. DispatchQueue.main.asyncAfter(deadline: .now() + 3) {
  380. showCheckmark = false
  381. }
  382. }
  383. Image(systemName: "xmark.circle").foregroundColor(showCheckmark && isSelected ? Color.clear : Color.secondary)
  384. .contentShape(Rectangle())
  385. .padding(.vertical)
  386. .onTapGesture {
  387. removeAlert = Alert(
  388. title: Text("Are you sure?"),
  389. message: Text("Delete preset \"\(preset.displayName)\""),
  390. primaryButton: .destructive(Text("Delete"), action: { state.removePreset(id: preset.id) }),
  391. secondaryButton: .cancel()
  392. )
  393. isRemoveAlertPresented = true
  394. }
  395. .alert(isPresented: $isRemoveAlertPresented) {
  396. removeAlert!
  397. }
  398. }
  399. if showCheckmark && isSelected {
  400. // show checkmark to indicate if the preset was actually pressed
  401. Image(systemName: "checkmark.circle.fill")
  402. .imageScale(.large)
  403. .fontWeight(.bold)
  404. .foregroundStyle(Color.green)
  405. }
  406. })
  407. }
  408. @ViewBuilder private func overridesView(for preset: OverrideStored) -> some View {
  409. let target = (state.units == .mgdL ? preset.target : preset.target?.decimalValue.asMmolL as NSDecimalNumber?) ?? 0
  410. let duration = (preset.duration ?? 0) as Decimal
  411. let name = ((preset.name ?? "") == "") || (preset.name?.isEmpty ?? true) ? "" : preset.name!
  412. let percent = preset.percentage / 100
  413. let perpetual = preset.indefinite
  414. let durationString = perpetual ? "" : "\(formatter.string(from: duration as NSNumber)!)"
  415. let scheduledSMBstring = (preset.smbIsOff && preset.smbIsAlwaysOff) ? "Scheduled SMBs" : ""
  416. let smbString = (preset.smbIsOff && scheduledSMBstring == "") ? "SMBs are off" : ""
  417. let targetString = target != 0 ? target.description : ""
  418. let maxMinutesSMB = (preset.smbMinutes as Decimal?) != nil ? (preset.smbMinutes ?? 0) as Decimal : 0
  419. let maxMinutesUAM = (preset.uamMinutes as Decimal?) != nil ? (preset.uamMinutes ?? 0) as Decimal : 0
  420. let isfString = preset.isf ? "ISF" : ""
  421. let crString = preset.cr ? "CR" : ""
  422. let dash = crString != "" ? "/" : ""
  423. let isfAndCRstring = isfString + dash + crString
  424. let isSelected = preset.id == selectedPresetID
  425. if name != "" {
  426. ZStack(alignment: .trailing, content: {
  427. HStack {
  428. VStack {
  429. HStack {
  430. Text(name)
  431. Spacer()
  432. }
  433. HStack(spacing: 5) {
  434. Text(percent.formatted(.percent.grouping(.never).rounded().precision(.fractionLength(0))))
  435. if targetString != "" {
  436. Text(targetString)
  437. Text(targetString != "" ? state.units.rawValue : "")
  438. }
  439. if durationString != "" { Text(durationString + (perpetual ? "" : "min")) }
  440. if smbString != "" { Text(smbString).foregroundColor(.secondary).font(.caption) }
  441. if scheduledSMBstring != "" { Text(scheduledSMBstring) }
  442. if preset.advancedSettings {
  443. Text(maxMinutesSMB == 0 ? "" : maxMinutesSMB.formatted() + " SMB")
  444. Text(maxMinutesUAM == 0 ? "" : maxMinutesUAM.formatted() + " UAM")
  445. Text(isfAndCRstring)
  446. }
  447. Spacer()
  448. }
  449. .padding(.top, 2)
  450. .foregroundColor(.secondary)
  451. .font(.caption)
  452. }
  453. .contentShape(Rectangle())
  454. .onTapGesture {
  455. Task {
  456. let objectID = preset.objectID
  457. await state.enactOverridePreset(withID: objectID)
  458. state.hideModal()
  459. showCheckmark.toggle()
  460. selectedPresetID = preset.id
  461. // deactivate showCheckmark after 3 seconds
  462. DispatchQueue.main.asyncAfter(deadline: .now() + 3) {
  463. showCheckmark = false
  464. }
  465. }
  466. }
  467. }
  468. // show checkmark to indicate if the preset was actually pressed
  469. if showCheckmark && isSelected {
  470. Image(systemName: "checkmark.circle.fill")
  471. .imageScale(.large)
  472. .fontWeight(.bold)
  473. .foregroundStyle(Color.green)
  474. } else {
  475. Image(systemName: "line.3.horizontal")
  476. .imageScale(.medium)
  477. .foregroundStyle(.secondary)
  478. }
  479. })
  480. }
  481. }
  482. }
  483. }