OverrideRootView.swift 27 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619
  1. import CoreData
  2. import SwiftUI
  3. import Swinject
  4. extension OverrideConfig {
  5. struct RootView: BaseView {
  6. let resolver: Resolver
  7. @State var state = StateModel()
  8. @State private var isEditing = false
  9. @State private var showOverrideCreationSheet = false
  10. @State private var showTempTargetCreationSheet = false
  11. @State private var showingDetail = false
  12. @State private var showCheckmark: Bool = false
  13. @State private var selectedPresetID: String?
  14. @State private var selectedTempTargetPresetID: String?
  15. @State private var selectedOverride: OverrideStored?
  16. @State private var selectedTempTarget: TempTargetStored?
  17. // temp targets
  18. @State private var isConfirmDeleteShown = false
  19. @State private var isPromptPresented = false
  20. @State private var isRemoveAlertPresented = false
  21. @State private var removeAlert: Alert?
  22. @State private var isEditingTT = false
  23. @Environment(\.managedObjectContext) var moc
  24. @Environment(\.colorScheme) var colorScheme
  25. var color: LinearGradient {
  26. colorScheme == .dark ? LinearGradient(
  27. gradient: Gradient(colors: [
  28. Color.bgDarkBlue,
  29. Color.bgDarkerDarkBlue
  30. ]),
  31. startPoint: .top,
  32. endPoint: .bottom
  33. )
  34. :
  35. LinearGradient(
  36. gradient: Gradient(colors: [Color.gray.opacity(0.1)]),
  37. startPoint: .top,
  38. endPoint: .bottom
  39. )
  40. }
  41. private var formatter: NumberFormatter {
  42. let formatter = NumberFormatter()
  43. formatter.numberStyle = .decimal
  44. formatter.maximumFractionDigits = 0
  45. return formatter
  46. }
  47. private var glucoseFormatter: NumberFormatter {
  48. let formatter = NumberFormatter()
  49. formatter.numberStyle = .decimal
  50. formatter.maximumFractionDigits = 0
  51. if state.units == .mmolL {
  52. formatter.maximumFractionDigits = 1
  53. }
  54. formatter.roundingMode = .halfUp
  55. return formatter
  56. }
  57. private func formattedGlucose(glucose: Decimal) -> String {
  58. let formattedValue: String
  59. if state.units == .mgdL {
  60. formattedValue = glucoseFormatter.string(from: glucose as NSDecimalNumber) ?? "\(glucose)"
  61. } else {
  62. formattedValue = glucose.formattedAsMmolL
  63. }
  64. return "\(formattedValue) \(state.units.rawValue)"
  65. }
  66. var body: some View {
  67. VStack {
  68. HStack(spacing: 6) {
  69. HStack {
  70. Spacer()
  71. Image(systemName: "clock.arrow.2.circlepath")
  72. .font(.system(size: 20))
  73. .foregroundStyle(Color.primary, Color(red: 0.6235294118, green: 0.4235294118, blue: 0.9803921569))
  74. Text(OverrideConfig.Tab.overrides.name)
  75. .font(.subheadline)
  76. .lineLimit(1)
  77. .minimumScaleFactor(0.8)
  78. Spacer()
  79. }
  80. .padding(.vertical, 6)
  81. .background(state.selectedTab == .overrides ? Color.loopGray.opacity(0.4) : Color.clear)
  82. .cornerRadius(8)
  83. .onTapGesture {
  84. withAnimation {
  85. state.selectedTab = .overrides
  86. }
  87. }
  88. HStack {
  89. Spacer()
  90. Image(systemName: "target")
  91. .font(.system(size: 20))
  92. .foregroundStyle(Color.loopGreen)
  93. Text(OverrideConfig.Tab.tempTargets.name)
  94. .font(.subheadline)
  95. .lineLimit(1)
  96. .minimumScaleFactor(0.8)
  97. Spacer()
  98. }
  99. .padding(.vertical, 6)
  100. .background(state.selectedTab == .tempTargets ? Color.loopGray.opacity(0.4) : Color.clear)
  101. .cornerRadius(8)
  102. .onTapGesture {
  103. withAnimation {
  104. state.selectedTab = .tempTargets
  105. }
  106. }
  107. }
  108. .background(Color.gray.opacity(0.2))
  109. .cornerRadius(8)
  110. .padding(.horizontal)
  111. Form {
  112. switch state.selectedTab {
  113. case .overrides: overrides()
  114. case .tempTargets: tempTargets() }
  115. }.scrollContentBackground(.hidden).background(color)
  116. .onAppear(perform: configureView)
  117. .navigationBarTitle("Adjustments")
  118. .navigationBarTitleDisplayMode(.large)
  119. .toolbar {
  120. ToolbarItem(placement: .topBarTrailing) {
  121. switch state.selectedTab {
  122. case .overrides:
  123. Button(action: {
  124. showOverrideCreationSheet = true
  125. }, label: {
  126. HStack {
  127. Text("Add Override")
  128. Image(systemName: "plus")
  129. }
  130. })
  131. case .tempTargets:
  132. Button(action: {
  133. showTempTargetCreationSheet = true
  134. }, label: {
  135. HStack {
  136. Text("Add Temp Target")
  137. Image(systemName: "plus")
  138. }
  139. })
  140. }
  141. }
  142. }
  143. .sheet(isPresented: $state.showOverrideEditSheet, onDismiss: {
  144. Task {
  145. await state.resetStateVariables()
  146. state.showOverrideEditSheet = false
  147. }
  148. }) {
  149. if let override = selectedOverride {
  150. EditOverrideForm(overrideToEdit: override, state: state)
  151. }
  152. }
  153. .sheet(isPresented: $showOverrideCreationSheet, onDismiss: {
  154. Task {
  155. await state.resetStateVariables()
  156. showOverrideCreationSheet = false
  157. }
  158. }) {
  159. AddOverrideForm(state: state)
  160. }
  161. .sheet(isPresented: $showTempTargetCreationSheet, onDismiss: {
  162. Task {
  163. await state.resetTempTargetState()
  164. showTempTargetCreationSheet = false
  165. }
  166. }) {
  167. AddTempTargetForm(state: state)
  168. }
  169. .sheet(isPresented: $state.showTempTargetEditSheet, onDismiss: {
  170. Task {
  171. await state.resetTempTargetState()
  172. state.showTempTargetEditSheet = false
  173. }
  174. }) {
  175. if let tempTarget = selectedTempTarget {
  176. EditTempTargetForm(tempTargetToEdit: tempTarget, state: state)
  177. }
  178. }
  179. }.background(color)
  180. }
  181. @ViewBuilder func overrides() -> some View {
  182. if state.overridePresets.isNotEmpty {
  183. overridePresets
  184. } else {
  185. defaultText
  186. }
  187. if state.isEnabled, state.activeOverrideName.isNotEmpty {
  188. currentActiveAdjustment
  189. }
  190. if state.overridePresets.isNotEmpty || state.currentActiveOverride != nil {
  191. cancelAdjustmentButton
  192. }
  193. }
  194. @ViewBuilder func tempTargets() -> some View {
  195. if state.tempTargetPresets.isNotEmpty {
  196. tempTargetPresets
  197. } else {
  198. defaultText
  199. }
  200. if state.isTempTargetEnabled, state.activeTempTargetName.isNotEmpty {
  201. currentActiveAdjustment
  202. }
  203. if state.tempTargetPresets.isNotEmpty || state.currentActiveTempTarget != nil {
  204. cancelAdjustmentButton
  205. }
  206. }
  207. private var defaultText: some View {
  208. switch state.selectedTab {
  209. case .overrides:
  210. Section {} header: {
  211. Text("Add Preset or Override by tapping 'Add Override +' in the top right-hand corner of the screen.")
  212. .textCase(nil)
  213. .foregroundStyle(.secondary)
  214. }
  215. case .tempTargets:
  216. Section {} header: {
  217. Text(
  218. "Add Preset or Temp Target by tapping 'Add Temp Target +' in the top right-hand corner of the screen."
  219. )
  220. .textCase(nil)
  221. .foregroundStyle(.secondary)
  222. }
  223. }
  224. }
  225. private var overridePresets: some View {
  226. Section {
  227. ForEach(state.overridePresets) { preset in
  228. overridesView(for: preset)
  229. .swipeActions(edge: .trailing, allowsFullSwipe: true) {
  230. Button(role: .none) {
  231. selectedOverride = preset
  232. isConfirmDeleteShown = true
  233. } label: {
  234. Label("Delete", systemImage: "trash")
  235. .tint(.red)
  236. }
  237. Button(action: {
  238. // Set the selected Override to the chosen Preset and pass it to the Edit Sheet
  239. selectedOverride = preset
  240. state.showOverrideEditSheet = true
  241. }, label: {
  242. Label("Edit", systemImage: "pencil")
  243. .tint(.blue)
  244. })
  245. }
  246. }
  247. .onMove(perform: state.reorderOverride)
  248. .confirmationDialog(
  249. "Delete the Override Preset \"\(selectedOverride?.name ?? "")\"?",
  250. isPresented: $isConfirmDeleteShown,
  251. titleVisibility: .visible
  252. ) {
  253. if let itemToDelete = selectedOverride {
  254. Button(
  255. state.currentActiveOverride == selectedOverride ? "Stop and Delete" : "Delete",
  256. role: .destructive
  257. ) {
  258. if state.currentActiveOverride == selectedOverride {
  259. Task {
  260. // Save cancelled Override in OverrideRunStored Entity
  261. // Cancel ALL active Override
  262. await state.disableAllActiveOverrides(createOverrideRunEntry: true)
  263. }
  264. }
  265. // Perform the delete action
  266. Task {
  267. await state.invokeOverridePresetDeletion(itemToDelete.objectID)
  268. }
  269. // Reset the selected item after deletion
  270. selectedOverride = nil
  271. }
  272. }
  273. Button("Cancel", role: .cancel) {
  274. // Dismiss the dialog without action
  275. selectedOverride = nil
  276. }
  277. } message: {
  278. if state.currentActiveOverride == selectedOverride {
  279. Text(
  280. state
  281. .currentActiveOverride == selectedOverride ?
  282. "This override preset is currently running. Deleting will stop it." : ""
  283. )
  284. }
  285. }
  286. .listRowBackground(Color.chart)
  287. } header: {
  288. Text("Presets")
  289. } footer: {
  290. HStack {
  291. Image(systemName: "hand.draw.fill")
  292. Text("Swipe left to edit or delete an override preset. Hold, drag and drop to reorder a preset.")
  293. }
  294. }
  295. }
  296. private var tempTargetPresets: some View {
  297. Section {
  298. ForEach(state.tempTargetPresets) { preset in
  299. tempTargetView(for: preset)
  300. .swipeActions(edge: .trailing, allowsFullSwipe: true) {
  301. Button(role: .none) {
  302. Task {
  303. await state.invokeTempTargetPresetDeletion(preset.objectID)
  304. }
  305. } label: {
  306. Label("Delete", systemImage: "trash")
  307. .tint(.red)
  308. }
  309. Button(action: {
  310. // Set the selected Temp Target to the chosen Preset and pass it to the Edit Sheet
  311. selectedTempTarget = preset
  312. state.showTempTargetEditSheet = true
  313. }, label: {
  314. Label("Edit", systemImage: "pencil")
  315. .tint(.blue)
  316. })
  317. }
  318. }
  319. .onMove(perform: state.reorderTempTargets)
  320. .listRowBackground(Color.chart)
  321. } header: {
  322. Text("Presets")
  323. } footer: {
  324. HStack {
  325. Image(systemName: "hand.draw.fill")
  326. Text("Swipe left to edit or delete an Temp Target preset. Hold, drag and drop to reorder a preset.")
  327. }
  328. }
  329. }
  330. private var currentActiveAdjustment: some View {
  331. switch state.selectedTab {
  332. case .overrides:
  333. Section {
  334. HStack {
  335. Text("\(state.activeOverrideName) is running")
  336. Spacer()
  337. Image(systemName: "square.and.pencil")
  338. .foregroundStyle(Color.blue)
  339. }
  340. .contentShape(Rectangle())
  341. .onTapGesture {
  342. Task {
  343. /// To avoid editing the Preset when a Preset-Override is running we first duplicate the Preset-Override as a non-Preset Override
  344. /// The currentActiveOverride variable in the State will update automatically via MOC notification
  345. await state.duplicateOverridePresetAndCancelPreviousOverride()
  346. /// selectedOverride is used for passing the chosen Override to the EditSheet so we have to set the updated currentActiveOverride to be the selectedOverride
  347. selectedOverride = state.currentActiveOverride
  348. /// Now we can show the Edit sheet
  349. state.showOverrideEditSheet = true
  350. }
  351. }
  352. }
  353. .listRowBackground(Color.blue.opacity(0.2))
  354. case .tempTargets:
  355. Section {
  356. HStack {
  357. Text("\(state.activeTempTargetName) is running")
  358. Spacer()
  359. Image(systemName: "square.and.pencil")
  360. .foregroundStyle(Color.blue)
  361. }
  362. .contentShape(Rectangle())
  363. .onTapGesture {
  364. Task {
  365. /// To avoid editing the Preset when a Preset-Override is running we first duplicate the Preset-Override as a non-Preset Override
  366. /// The currentActiveOverride variable in the State will update automatically via MOC notification
  367. await state.duplicateTempTargetPresetAndCancelPreviousTempTarget()
  368. /// selectedOverride is used for passing the chosen Override to the EditSheet so we have to set the updated currentActiveOverride to be the selectedOverride
  369. selectedTempTarget = state.currentActiveTempTarget
  370. /// Now we can show the Edit sheet
  371. state.showTempTargetEditSheet = true
  372. }
  373. }
  374. }
  375. .listRowBackground(Color.blue.opacity(0.2))
  376. }
  377. }
  378. private var cancelAdjustmentButton: some View {
  379. switch state.selectedTab {
  380. case .overrides:
  381. Button(action: {
  382. Task {
  383. // Save cancelled Override in OverrideRunStored Entity
  384. // Cancel ALL active Override
  385. await state.disableAllActiveOverrides(createOverrideRunEntry: true)
  386. }
  387. }, label: {
  388. Text("Cancel Override")
  389. })
  390. .frame(maxWidth: .infinity, alignment: .center)
  391. .disabled(!state.isEnabled)
  392. .listRowBackground(!state.isEnabled ? Color(.systemGray4) : Color(.systemRed))
  393. .tint(.white)
  394. case .tempTargets:
  395. Button(action: {
  396. Task {
  397. // Save cancelled Temp Targets in TempTargetRunStored Entity
  398. // Cancel ALL active Temp Targets
  399. await state.disableAllActiveTempTargets(createTempTargetRunEntry: true)
  400. // Update View
  401. state.updateLatestTempTargetConfiguration()
  402. }
  403. }, label: {
  404. Text("Cancel Temp Target")
  405. })
  406. .frame(maxWidth: .infinity, alignment: .center)
  407. .disabled(!state.isTempTargetEnabled)
  408. .listRowBackground(!state.isTempTargetEnabled ? Color(.systemGray4) : Color(.systemRed))
  409. .tint(.white)
  410. }
  411. }
  412. private func tempTargetView(for preset: TempTargetStored) -> some View {
  413. var target = preset.target
  414. let presetTarget = Decimal(target as! Double.RawValue)
  415. let isSelected = preset.id?.uuidString == selectedTempTargetPresetID
  416. let presetHalfBasalTarget = Decimal(
  417. preset.halfBasalTarget as? Double
  418. .RawValue ?? Double(state.settingHalfBasalTarget)
  419. )
  420. let percentage = Int(
  421. state
  422. .computeAdjustedPercentage(usingHBT: presetHalfBasalTarget, usingTarget: presetTarget) * 100
  423. )
  424. return ZStack(alignment: .trailing, content: {
  425. HStack {
  426. VStack {
  427. HStack {
  428. Text(preset.name ?? "")
  429. Spacer()
  430. }
  431. HStack(spacing: 2) {
  432. Text(formattedGlucose(glucose: target as! Decimal))
  433. .foregroundColor(.secondary)
  434. .font(.caption)
  435. Text("for")
  436. .foregroundColor(.secondary)
  437. .font(.caption)
  438. Text("\(formatter.string(from: (preset.duration ?? 0) as NSNumber)!)")
  439. .foregroundColor(.secondary)
  440. .font(.caption)
  441. Text("min")
  442. .foregroundColor(.secondary)
  443. .font(.caption)
  444. if state.isAdjustSensEnabled(usingTarget: presetTarget) { Text(", \(percentage)%")
  445. .foregroundColor(.secondary)
  446. .font(.caption)
  447. }
  448. Spacer()
  449. }.padding(.top, 2)
  450. }
  451. .contentShape(Rectangle())
  452. .onTapGesture {
  453. Task {
  454. let objectID = preset.objectID
  455. await state.enactTempTargetPreset(withID: objectID)
  456. selectedTempTargetPresetID = preset.id?.uuidString
  457. showCheckmark.toggle()
  458. DispatchQueue.main.asyncAfter(deadline: .now() + 3) {
  459. showCheckmark = false
  460. }
  461. }
  462. }
  463. }
  464. if showCheckmark && isSelected {
  465. // show checkmark to indicate if the preset was actually pressed
  466. Image(systemName: "checkmark.circle.fill")
  467. .imageScale(.large)
  468. .fontWeight(.bold)
  469. .foregroundStyle(Color.green)
  470. } else {
  471. Image(systemName: "line.3.horizontal")
  472. .imageScale(.medium)
  473. .foregroundStyle(.secondary)
  474. }
  475. })
  476. }
  477. private var overrideLabelDivider: some View {
  478. Divider()
  479. .frame(width: 1, height: 20)
  480. }
  481. @ViewBuilder private func overridesView(for preset: OverrideStored) -> some View {
  482. let target = (state.units == .mgdL ? preset.target : preset.target?.decimalValue.asMmolL as NSDecimalNumber?) ?? 0
  483. let duration = (preset.duration ?? 0) as Decimal
  484. let name = preset.name ?? ""
  485. let percent = preset.percentage / 100
  486. let perpetual = preset.indefinite
  487. let durationString = perpetual ? "" : "\(formatHrMin(Int(duration)))"
  488. let scheduledSMBstring = preset.smbIsScheduledOff && preset.start != preset.end
  489. ? " \(formatTimeRange(start: preset.start?.stringValue, end: preset.end?.stringValue))"
  490. : ""
  491. let smbString = (preset.smbIsOff || preset.smbIsScheduledOff) ? "SMBs Off\(scheduledSMBstring)" : ""
  492. let targetString = target != 0 ? "\(target.description) \(state.units.rawValue)" : ""
  493. let maxMinutesSMB = (preset.smbMinutes as Decimal?) != nil ? (preset.smbMinutes ?? 0) as Decimal : 0
  494. let maxMinutesUAM = (preset.uamMinutes as Decimal?) != nil ? (preset.uamMinutes ?? 0) as Decimal : 0
  495. let maxSmbMinsString = (
  496. maxMinutesSMB != 0 && preset.advancedSettings && !preset.smbIsOff && maxMinutesSMB != state
  497. .defaultSmbMinutes
  498. ) ?
  499. "\(maxMinutesSMB.formatted()) min SMB" : ""
  500. let maxUamMinsString = (
  501. maxMinutesUAM != 0 && preset.advancedSettings && !preset.smbIsOff && maxMinutesUAM != state
  502. .defaultUamMinutes
  503. ) ?
  504. "\(maxMinutesUAM.formatted()) min UAM" : ""
  505. let isfAndCRstring: String = {
  506. switch (preset.isfAndCr, preset.isf, preset.cr) {
  507. case (_, true, true),
  508. (true, _, _):
  509. return " ISF/CR"
  510. case (false, true, false):
  511. return " ISF"
  512. case (false, false, true):
  513. return " CR"
  514. default:
  515. return ""
  516. }
  517. }()
  518. let isSelected = preset.id == selectedPresetID
  519. let labels: [String] = [
  520. durationString,
  521. percent != 1 ? "\(Int(percent * 100))%\(isfAndCRstring)" : "",
  522. targetString,
  523. smbString,
  524. maxSmbMinsString,
  525. maxUamMinsString
  526. ].filter { !$0.isEmpty } // filter out empty labels
  527. if !name.isEmpty {
  528. ZStack(alignment: .trailing) {
  529. HStack {
  530. VStack {
  531. HStack {
  532. Text(name)
  533. Spacer()
  534. }
  535. HStack(spacing: 5) {
  536. ForEach(labels, id: \.self) { label in
  537. Text(label)
  538. if label != labels.last { // Add divider between labels
  539. overrideLabelDivider
  540. }
  541. }
  542. Spacer()
  543. }
  544. .padding(.top, 2)
  545. .foregroundColor(.secondary)
  546. .font(.caption)
  547. }
  548. .contentShape(Rectangle())
  549. .onTapGesture {
  550. Task {
  551. let objectID = preset.objectID
  552. await state.enactOverridePreset(withID: objectID)
  553. state.hideModal()
  554. showCheckmark.toggle()
  555. selectedPresetID = preset.id
  556. // Deactivate checkmark after 3 seconds
  557. DispatchQueue.main.asyncAfter(deadline: .now() + 3) {
  558. showCheckmark = false
  559. }
  560. }
  561. }
  562. }
  563. // show checkmark to indicate if the preset was actually pressed
  564. if showCheckmark && isSelected {
  565. Image(systemName: "checkmark.circle.fill")
  566. .imageScale(.large)
  567. .fontWeight(.bold)
  568. .foregroundStyle(Color.green)
  569. } else {
  570. Image(systemName: "line.3.horizontal")
  571. .imageScale(.medium)
  572. .foregroundStyle(.secondary)
  573. }
  574. }
  575. }
  576. }
  577. }
  578. }