OverrideRootView.swift 29 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657
  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. selectedTempTarget = preset
  304. isConfirmDeleteShown = true
  305. }
  306. } label: {
  307. Label("Delete", systemImage: "trash")
  308. .tint(.red)
  309. }
  310. Button(action: {
  311. // Set the selected Temp Target to the chosen Preset and pass it to the Edit Sheet
  312. selectedTempTarget = preset
  313. state.showTempTargetEditSheet = true
  314. }, label: {
  315. Label("Edit", systemImage: "pencil")
  316. .tint(.blue)
  317. })
  318. }
  319. }
  320. .onMove(perform: state.reorderTempTargets)
  321. .confirmationDialog(
  322. "Delete the Temp Target Preset \"\(selectedTempTarget?.name ?? "")\"?",
  323. isPresented: $isConfirmDeleteShown,
  324. titleVisibility: .visible
  325. ) {
  326. if let itemToDelete = selectedTempTarget {
  327. Button(
  328. state.currentActiveTempTarget == selectedTempTarget ? "Stop and Delete" : "Delete",
  329. role: .destructive
  330. ) {
  331. if state.currentActiveTempTarget == selectedTempTarget {
  332. Task {
  333. // Save cancelled Temp Target in Temp Target run Entity
  334. await state.disableAllActiveTempTargets(createTempTargetRunEntry: true)
  335. }
  336. }
  337. // Perform the stop action
  338. Task {
  339. await state.invokeTempTargetPresetDeletion(itemToDelete.objectID)
  340. }
  341. // Reset the selected item after deletion
  342. selectedTempTarget = nil
  343. }
  344. }
  345. Button("Cancel", role: .cancel) {
  346. // Dismiss the dialog without action
  347. selectedTempTarget = nil
  348. }
  349. } message: {
  350. if state.currentActiveTempTarget == selectedTempTarget {
  351. Text(
  352. state
  353. .currentActiveTempTarget == selectedTempTarget ?
  354. "This Temp Target preset is currently running. Deleting will stop it." : ""
  355. )
  356. }
  357. }
  358. .listRowBackground(Color.chart)
  359. } header: {
  360. Text("Presets")
  361. } footer: {
  362. HStack {
  363. Image(systemName: "hand.draw.fill")
  364. Text("Swipe left to edit or delete a Temp Target preset. Hold, drag and drop to reorder a preset.")
  365. }
  366. }
  367. }
  368. private var currentActiveAdjustment: some View {
  369. switch state.selectedTab {
  370. case .overrides:
  371. Section {
  372. HStack {
  373. Text("\(state.activeOverrideName) is running")
  374. Spacer()
  375. Image(systemName: "square.and.pencil")
  376. .foregroundStyle(Color.blue)
  377. }
  378. .contentShape(Rectangle())
  379. .onTapGesture {
  380. Task {
  381. /// To avoid editing the Preset when a Preset-Override is running we first duplicate the Preset-Override as a non-Preset Override
  382. /// The currentActiveOverride variable in the State will update automatically via MOC notification
  383. await state.duplicateOverridePresetAndCancelPreviousOverride()
  384. /// selectedOverride is used for passing the chosen Override to the EditSheet so we have to set the updated currentActiveOverride to be the selectedOverride
  385. selectedOverride = state.currentActiveOverride
  386. /// Now we can show the Edit sheet
  387. state.showOverrideEditSheet = true
  388. }
  389. }
  390. }
  391. .listRowBackground(Color.blue.opacity(0.2))
  392. case .tempTargets:
  393. Section {
  394. HStack {
  395. Text("\(state.activeTempTargetName) is running")
  396. Spacer()
  397. Image(systemName: "square.and.pencil")
  398. .foregroundStyle(Color.blue)
  399. }
  400. .contentShape(Rectangle())
  401. .onTapGesture {
  402. Task {
  403. /// To avoid editing the Preset when a Preset-Override is running we first duplicate the Preset-Override as a non-Preset Override
  404. /// The currentActiveOverride variable in the State will update automatically via MOC notification
  405. await state.duplicateTempTargetPresetAndCancelPreviousTempTarget()
  406. /// selectedOverride is used for passing the chosen Override to the EditSheet so we have to set the updated currentActiveOverride to be the selectedOverride
  407. selectedTempTarget = state.currentActiveTempTarget
  408. /// Now we can show the Edit sheet
  409. state.showTempTargetEditSheet = true
  410. }
  411. }
  412. }
  413. .listRowBackground(Color.blue.opacity(0.2))
  414. }
  415. }
  416. private var cancelAdjustmentButton: some View {
  417. switch state.selectedTab {
  418. case .overrides:
  419. Button(action: {
  420. Task {
  421. // Save cancelled Override in OverrideRunStored Entity
  422. // Cancel ALL active Override
  423. await state.disableAllActiveOverrides(createOverrideRunEntry: true)
  424. }
  425. }, label: {
  426. Text("Cancel Override")
  427. })
  428. .frame(maxWidth: .infinity, alignment: .center)
  429. .disabled(!state.isEnabled)
  430. .listRowBackground(!state.isEnabled ? Color(.systemGray4) : Color(.systemRed))
  431. .tint(.white)
  432. case .tempTargets:
  433. Button(action: {
  434. Task {
  435. // Save cancelled Temp Targets in TempTargetRunStored Entity
  436. // Cancel ALL active Temp Targets
  437. await state.disableAllActiveTempTargets(createTempTargetRunEntry: true)
  438. // Update View
  439. state.updateLatestTempTargetConfiguration()
  440. }
  441. }, label: {
  442. Text("Cancel Temp Target")
  443. })
  444. .frame(maxWidth: .infinity, alignment: .center)
  445. .disabled(!state.isTempTargetEnabled)
  446. .listRowBackground(!state.isTempTargetEnabled ? Color(.systemGray4) : Color(.systemRed))
  447. .tint(.white)
  448. }
  449. }
  450. private func tempTargetView(for preset: TempTargetStored) -> some View {
  451. let target = preset.target ?? 100
  452. let presetTarget = Decimal(target as! Double.RawValue)
  453. let isSelected = preset.id?.uuidString == selectedTempTargetPresetID
  454. let presetHalfBasalTarget = Decimal(
  455. preset.halfBasalTarget as? Double
  456. .RawValue ?? Double(state.settingHalfBasalTarget)
  457. )
  458. let percentage = Int(
  459. state
  460. .computeAdjustedPercentage(usingHBT: presetHalfBasalTarget, usingTarget: presetTarget) * 100
  461. )
  462. return ZStack(alignment: .trailing, content: {
  463. HStack {
  464. VStack {
  465. HStack {
  466. Text(preset.name ?? "")
  467. Spacer()
  468. }
  469. HStack(spacing: 2) {
  470. Text(formattedGlucose(glucose: target as! Decimal))
  471. .foregroundColor(.secondary)
  472. .font(.caption)
  473. Text("for")
  474. .foregroundColor(.secondary)
  475. .font(.caption)
  476. Text("\(formatter.string(from: (preset.duration ?? 0) as NSNumber)!)")
  477. .foregroundColor(.secondary)
  478. .font(.caption)
  479. Text("min")
  480. .foregroundColor(.secondary)
  481. .font(.caption)
  482. if state.isAdjustSensEnabled(usingTarget: presetTarget) { Text(", \(percentage)%")
  483. .foregroundColor(.secondary)
  484. .font(.caption)
  485. }
  486. Spacer()
  487. }.padding(.top, 2)
  488. }
  489. .contentShape(Rectangle())
  490. .onTapGesture {
  491. Task {
  492. let objectID = preset.objectID
  493. await state.enactTempTargetPreset(withID: objectID)
  494. selectedTempTargetPresetID = preset.id?.uuidString
  495. showCheckmark.toggle()
  496. DispatchQueue.main.asyncAfter(deadline: .now() + 3) {
  497. showCheckmark = false
  498. }
  499. }
  500. }
  501. }
  502. if showCheckmark && isSelected {
  503. // show checkmark to indicate if the preset was actually pressed
  504. Image(systemName: "checkmark.circle.fill")
  505. .imageScale(.large)
  506. .fontWeight(.bold)
  507. .foregroundStyle(Color.green)
  508. } else {
  509. Image(systemName: "line.3.horizontal")
  510. .imageScale(.medium)
  511. .foregroundStyle(.secondary)
  512. }
  513. })
  514. }
  515. private var overrideLabelDivider: some View {
  516. Divider()
  517. .frame(width: 1, height: 20)
  518. }
  519. @ViewBuilder private func overridesView(for preset: OverrideStored) -> some View {
  520. let target = (state.units == .mgdL ? preset.target : preset.target?.decimalValue.asMmolL as NSDecimalNumber?) ?? 0
  521. let duration = (preset.duration ?? 0) as Decimal
  522. let name = preset.name ?? ""
  523. let percentage = preset.percentage
  524. let perpetual = preset.indefinite
  525. let durationString = perpetual ? "" : "\(formatHrMin(Int(duration)))"
  526. let scheduledSMBstring = preset.smbIsScheduledOff && preset.start != preset.end
  527. ? " \(formatTimeRange(start: preset.start?.stringValue, end: preset.end?.stringValue))"
  528. : ""
  529. let smbString = (preset.smbIsOff || preset.smbIsScheduledOff) ? "SMBs Off\(scheduledSMBstring)" : ""
  530. let targetString = target != 0 ? "\(target.description) \(state.units.rawValue)" : ""
  531. let maxMinutesSMB = (preset.smbMinutes as Decimal?) != nil ? (preset.smbMinutes ?? 0) as Decimal : 0
  532. let maxMinutesUAM = (preset.uamMinutes as Decimal?) != nil ? (preset.uamMinutes ?? 0) as Decimal : 0
  533. let maxSmbMinsString = (
  534. maxMinutesSMB != 0 && preset.advancedSettings && !preset.smbIsOff && maxMinutesSMB != state
  535. .defaultSmbMinutes
  536. ) ?
  537. "\(maxMinutesSMB.formatted()) min SMB" : ""
  538. let maxUamMinsString = (
  539. maxMinutesUAM != 0 && preset.advancedSettings && !preset.smbIsOff && maxMinutesUAM != state
  540. .defaultUamMinutes
  541. ) ?
  542. "\(maxMinutesUAM.formatted()) min UAM" : ""
  543. let isfAndCRstring: String = {
  544. switch (preset.isfAndCr, preset.isf, preset.cr) {
  545. case (_, true, true),
  546. (true, _, _):
  547. return " ISF/CR"
  548. case (false, true, false):
  549. return " ISF"
  550. case (false, false, true):
  551. return " CR"
  552. default:
  553. return ""
  554. }
  555. }()
  556. let isSelected = preset.id == selectedPresetID
  557. let labels: [String] = [
  558. durationString,
  559. percentage != 100 ? "\(Int(percentage))%\(isfAndCRstring)" : "",
  560. targetString,
  561. smbString,
  562. maxSmbMinsString,
  563. maxUamMinsString
  564. ].filter { !$0.isEmpty } // filter out empty labels
  565. if !name.isEmpty {
  566. ZStack(alignment: .trailing) {
  567. HStack {
  568. VStack {
  569. HStack {
  570. Text(name)
  571. Spacer()
  572. }
  573. HStack(spacing: 5) {
  574. ForEach(labels, id: \.self) { label in
  575. Text(label)
  576. if label != labels.last { // Add divider between labels
  577. overrideLabelDivider
  578. }
  579. }
  580. Spacer()
  581. }
  582. .padding(.top, 2)
  583. .foregroundColor(.secondary)
  584. .font(.caption)
  585. }
  586. .contentShape(Rectangle())
  587. .onTapGesture {
  588. Task {
  589. let objectID = preset.objectID
  590. await state.enactOverridePreset(withID: objectID)
  591. state.hideModal()
  592. showCheckmark.toggle()
  593. selectedPresetID = preset.id
  594. // Deactivate checkmark after 3 seconds
  595. DispatchQueue.main.asyncAfter(deadline: .now() + 3) {
  596. showCheckmark = false
  597. }
  598. }
  599. }
  600. }
  601. // show checkmark to indicate if the preset was actually pressed
  602. if showCheckmark && isSelected {
  603. Image(systemName: "checkmark.circle.fill")
  604. .imageScale(.large)
  605. .fontWeight(.bold)
  606. .foregroundStyle(Color.green)
  607. } else {
  608. Image(systemName: "line.3.horizontal")
  609. .imageScale(.medium)
  610. .foregroundStyle(.secondary)
  611. }
  612. }
  613. }
  614. }
  615. }
  616. }