OverrideRootView.swift 29 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678
  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 isConfirmDeletePresented = 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. List {
  112. switch state.selectedTab {
  113. case .overrides: overrides()
  114. case .tempTargets: tempTargets() }
  115. }
  116. .listSectionSpacing(10)
  117. .scrollContentBackground(.hidden).background(color)
  118. .onAppear(perform: configureView)
  119. .navigationBarTitle("Adjustments")
  120. .navigationBarTitleDisplayMode(.large)
  121. .toolbar {
  122. ToolbarItem(placement: .topBarTrailing) {
  123. switch state.selectedTab {
  124. case .overrides:
  125. Button(action: {
  126. showOverrideCreationSheet = true
  127. }, label: {
  128. HStack {
  129. Text("Add Override")
  130. Image(systemName: "plus")
  131. }
  132. })
  133. case .tempTargets:
  134. Button(action: {
  135. showTempTargetCreationSheet = true
  136. }, label: {
  137. HStack {
  138. Text("Add Temp Target")
  139. Image(systemName: "plus")
  140. }
  141. })
  142. }
  143. }
  144. }
  145. .sheet(isPresented: $state.showOverrideEditSheet, onDismiss: {
  146. Task {
  147. await state.resetStateVariables()
  148. state.showOverrideEditSheet = false
  149. }
  150. }) {
  151. if let override = selectedOverride {
  152. EditOverrideForm(overrideToEdit: override, state: state)
  153. }
  154. }
  155. .sheet(isPresented: $showOverrideCreationSheet, onDismiss: {
  156. Task {
  157. await state.resetStateVariables()
  158. showOverrideCreationSheet = false
  159. }
  160. }) {
  161. AddOverrideForm(state: state)
  162. }
  163. .sheet(isPresented: $showTempTargetCreationSheet, onDismiss: {
  164. Task {
  165. await state.resetTempTargetState()
  166. showTempTargetCreationSheet = false
  167. }
  168. }) {
  169. AddTempTargetForm(state: state)
  170. }
  171. .sheet(isPresented: $state.showTempTargetEditSheet, onDismiss: {
  172. Task {
  173. await state.resetTempTargetState()
  174. state.showTempTargetEditSheet = false
  175. }
  176. }) {
  177. if let tempTarget = selectedTempTarget {
  178. EditTempTargetForm(tempTargetToEdit: tempTarget, state: state)
  179. }
  180. }
  181. }.background(color)
  182. }
  183. @ViewBuilder func overrides() -> some View {
  184. if state.isEnabled, state.activeOverrideName.isNotEmpty {
  185. currentActiveAdjustment
  186. cancelAdjustmentButton
  187. }
  188. if state.overridePresets.isNotEmpty {
  189. overridePresets
  190. } else {
  191. defaultText
  192. }
  193. // if state.overridePresets.isNotEmpty || state.currentActiveOverride != nil {
  194. // cancelAdjustmentButton
  195. // }
  196. }
  197. @ViewBuilder func tempTargets() -> some View {
  198. if state.isTempTargetEnabled, state.activeTempTargetName.isNotEmpty {
  199. currentActiveAdjustment
  200. cancelAdjustmentButton
  201. }
  202. if state.tempTargetPresets.isNotEmpty {
  203. tempTargetPresets
  204. } else {
  205. defaultText
  206. }
  207. // if state.tempTargetPresets.isNotEmpty || state.currentActiveTempTarget != nil {
  208. // cancelAdjustmentButton
  209. // }
  210. }
  211. private var defaultText: some View {
  212. switch state.selectedTab {
  213. case .overrides:
  214. Section {} header: {
  215. Text("Add Preset or Override by tapping 'Add Override +' in the top right-hand corner of the screen.")
  216. .textCase(nil)
  217. .foregroundStyle(.secondary)
  218. }
  219. case .tempTargets:
  220. Section {} header: {
  221. Text(
  222. "Add Preset or Temp Target by tapping 'Add Temp Target +' in the top right-hand corner of the screen."
  223. )
  224. .textCase(nil)
  225. .foregroundStyle(.secondary)
  226. }
  227. }
  228. }
  229. private var overridePresets: some View {
  230. Section {
  231. ForEach(state.overridePresets) { preset in
  232. overridesView(for: preset)
  233. .swipeActions(edge: .trailing, allowsFullSwipe: true) {
  234. Button(role: .none) {
  235. selectedOverride = preset
  236. isConfirmDeletePresented = true
  237. } label: {
  238. Label("Delete", systemImage: "trash")
  239. .tint(.red)
  240. }
  241. Button(action: {
  242. // Set the selected Override to the chosen Preset and pass it to the Edit Sheet
  243. selectedOverride = preset
  244. state.showOverrideEditSheet = true
  245. }, label: {
  246. Label("Edit", systemImage: "pencil")
  247. .tint(.blue)
  248. })
  249. }
  250. }
  251. .onMove(perform: state.reorderOverride)
  252. .confirmationDialog(
  253. "Delete the Override Preset \"\(selectedOverride?.name ?? "")\"?",
  254. isPresented: $isConfirmDeletePresented,
  255. titleVisibility: .visible
  256. ) {
  257. if let itemToDelete = selectedOverride {
  258. Button(
  259. state.currentActiveOverride == selectedOverride ? "Stop and Delete" : "Delete",
  260. role: .destructive
  261. ) {
  262. if state.currentActiveOverride == selectedOverride {
  263. Task {
  264. // Save cancelled Override in OverrideRunStored Entity
  265. // Cancel ALL active Override
  266. await state.disableAllActiveOverrides(createOverrideRunEntry: true)
  267. }
  268. }
  269. // Perform the delete action
  270. Task {
  271. await state.invokeOverridePresetDeletion(itemToDelete.objectID)
  272. }
  273. // Reset the selected item after deletion
  274. selectedOverride = nil
  275. }
  276. }
  277. Button("Cancel", role: .cancel) {
  278. // Dismiss the dialog without action
  279. selectedOverride = nil
  280. }
  281. } message: {
  282. if state.currentActiveOverride == selectedOverride {
  283. Text(
  284. state
  285. .currentActiveOverride == selectedOverride ?
  286. "This override preset is currently running. Deleting will stop it." : ""
  287. )
  288. }
  289. }
  290. .listRowBackground(Color.chart)
  291. } header: {
  292. Text("Presets")
  293. } footer: {
  294. HStack {
  295. Image(systemName: "hand.draw.fill")
  296. Text("Swipe left to edit or delete an override preset. Hold, drag and drop to reorder a preset.")
  297. }
  298. }
  299. }
  300. private var tempTargetPresets: some View {
  301. Section {
  302. ForEach(state.tempTargetPresets) { preset in
  303. tempTargetView(for: preset)
  304. .swipeActions(edge: .trailing, allowsFullSwipe: true) {
  305. Button(role: .none) {
  306. Task {
  307. selectedTempTarget = preset
  308. isConfirmDeletePresented = true
  309. }
  310. } label: {
  311. Label("Delete", systemImage: "trash")
  312. .tint(.red)
  313. }
  314. Button(action: {
  315. // Set the selected Temp Target to the chosen Preset and pass it to the Edit Sheet
  316. selectedTempTarget = preset
  317. state.showTempTargetEditSheet = true
  318. }, label: {
  319. Label("Edit", systemImage: "pencil")
  320. .tint(.blue)
  321. })
  322. }
  323. }
  324. .onMove(perform: state.reorderTempTargets)
  325. .confirmationDialog(
  326. "Delete the Temp Target Preset \"\(selectedTempTarget?.name ?? "")\"?",
  327. isPresented: $isConfirmDeletePresented,
  328. titleVisibility: .visible
  329. ) {
  330. if let itemToDelete = selectedTempTarget {
  331. Button(
  332. state.currentActiveTempTarget == selectedTempTarget ? "Stop and Delete" : "Delete",
  333. role: .destructive
  334. ) {
  335. if state.currentActiveTempTarget == selectedTempTarget {
  336. Task {
  337. // Save cancelled Temp Target in Temp Target run Entity
  338. await state.disableAllActiveTempTargets(createTempTargetRunEntry: true)
  339. }
  340. }
  341. // Perform the stop action
  342. Task {
  343. await state.invokeTempTargetPresetDeletion(itemToDelete.objectID)
  344. }
  345. // Reset the selected item after deletion
  346. selectedTempTarget = nil
  347. }
  348. }
  349. Button("Cancel", role: .cancel) {
  350. // Dismiss the dialog without action
  351. selectedTempTarget = nil
  352. }
  353. } message: {
  354. if state.currentActiveTempTarget == selectedTempTarget {
  355. Text(
  356. state
  357. .currentActiveTempTarget == selectedTempTarget ?
  358. "This Temp Target preset is currently running. Deleting will stop it." : ""
  359. )
  360. }
  361. }
  362. .listRowBackground(Color.chart)
  363. } header: {
  364. Text("Presets")
  365. } footer: {
  366. HStack {
  367. Image(systemName: "hand.draw.fill")
  368. Text("Swipe left to edit or delete a Temp Target preset. Hold, drag and drop to reorder a preset.")
  369. }
  370. }
  371. }
  372. private var currentActiveAdjustment: some View {
  373. switch state.selectedTab {
  374. case .overrides:
  375. Section {
  376. HStack {
  377. Text("\(state.activeOverrideName) is running")
  378. Spacer()
  379. Image(systemName: "square.and.pencil")
  380. .foregroundStyle(Color.blue)
  381. }
  382. .contentShape(Rectangle())
  383. .onTapGesture {
  384. Task {
  385. /// To avoid editing the Preset when a Preset-Override is running we first duplicate the Preset-Override as a non-Preset Override
  386. /// The currentActiveOverride variable in the State will update automatically via MOC notification
  387. await state.duplicateOverridePresetAndCancelPreviousOverride()
  388. /// selectedOverride is used for passing the chosen Override to the EditSheet so we have to set the updated currentActiveOverride to be the selectedOverride
  389. selectedOverride = state.currentActiveOverride
  390. /// Now we can show the Edit sheet
  391. state.showOverrideEditSheet = true
  392. }
  393. }
  394. }
  395. .listRowBackground(Color.blue.opacity(0.2))
  396. case .tempTargets:
  397. Section {
  398. HStack {
  399. Text("\(state.activeTempTargetName) is running")
  400. Spacer()
  401. Image(systemName: "square.and.pencil")
  402. .foregroundStyle(Color.blue)
  403. }
  404. .contentShape(Rectangle())
  405. .onTapGesture {
  406. Task {
  407. /// To avoid editing the Preset when a Preset-Override is running we first duplicate the Preset-Override as a non-Preset Override
  408. /// The currentActiveOverride variable in the State will update automatically via MOC notification
  409. await state.duplicateTempTargetPresetAndCancelPreviousTempTarget()
  410. /// selectedOverride is used for passing the chosen Override to the EditSheet so we have to set the updated currentActiveOverride to be the selectedOverride
  411. selectedTempTarget = state.currentActiveTempTarget
  412. /// Now we can show the Edit sheet
  413. state.showTempTargetEditSheet = true
  414. }
  415. }
  416. }
  417. .listRowBackground(Color.blue.opacity(0.2))
  418. }
  419. }
  420. private var cancelAdjustmentButton: some View {
  421. switch state.selectedTab {
  422. case .overrides:
  423. Button(action: {
  424. Task {
  425. // Save cancelled Override in OverrideRunStored Entity
  426. // Cancel ALL active Override
  427. await state.disableAllActiveOverrides(createOverrideRunEntry: true)
  428. }
  429. }, label: {
  430. Text("Stop Override")
  431. })
  432. .frame(maxWidth: .infinity, alignment: .center)
  433. .disabled(!state.isEnabled)
  434. .listRowBackground(!state.isEnabled ? Color(.systemGray4) : Color(.systemRed))
  435. .tint(.white)
  436. case .tempTargets:
  437. Button(action: {
  438. Task {
  439. // Save cancelled Temp Targets in TempTargetRunStored Entity
  440. // Cancel ALL active Temp Targets
  441. await state.disableAllActiveTempTargets(createTempTargetRunEntry: true)
  442. // Update View
  443. state.updateLatestTempTargetConfiguration()
  444. }
  445. }, label: {
  446. Text("Stop Temp Target")
  447. })
  448. .frame(maxWidth: .infinity, alignment: .center)
  449. .disabled(!state.isTempTargetEnabled)
  450. .listRowBackground(!state.isTempTargetEnabled ? Color(.systemGray4) : Color(.systemRed))
  451. .tint(.white)
  452. }
  453. }
  454. private func tempTargetView(for preset: TempTargetStored) -> some View {
  455. let target = preset.target ?? 100
  456. let presetTarget = Decimal(target as! Double.RawValue)
  457. let isSelected = preset.id?.uuidString == selectedTempTargetPresetID
  458. let presetHalfBasalTarget = Decimal(
  459. preset.halfBasalTarget as? Double
  460. .RawValue ?? Double(state.settingHalfBasalTarget)
  461. )
  462. let percentage = Int(
  463. state.computeAdjustedPercentage(usingHBT: presetHalfBasalTarget, usingTarget: presetTarget)
  464. )
  465. return ZStack(alignment: .trailing, content: {
  466. HStack {
  467. VStack {
  468. HStack {
  469. Text(preset.name ?? "")
  470. Spacer()
  471. }
  472. HStack(spacing: 2) {
  473. Text(formattedGlucose(glucose: target as! Decimal))
  474. .foregroundColor(.secondary)
  475. .font(.caption)
  476. Text("for")
  477. .foregroundColor(.secondary)
  478. .font(.caption)
  479. Text("\(formatter.string(from: (preset.duration ?? 0) as NSNumber)!)")
  480. .foregroundColor(.secondary)
  481. .font(.caption)
  482. Text("min")
  483. .foregroundColor(.secondary)
  484. .font(.caption)
  485. if state.isAdjustSensEnabled(usingTarget: presetTarget) { Text(", \(percentage)%")
  486. .foregroundColor(.secondary)
  487. .font(.caption)
  488. }
  489. Spacer()
  490. }.padding(.top, 2)
  491. }
  492. .contentShape(Rectangle())
  493. .onTapGesture {
  494. Task {
  495. let objectID = preset.objectID
  496. await state.enactTempTargetPreset(withID: objectID)
  497. selectedTempTargetPresetID = preset.id?.uuidString
  498. showCheckmark.toggle()
  499. DispatchQueue.main.asyncAfter(deadline: .now() + 3) {
  500. showCheckmark = false
  501. }
  502. }
  503. }
  504. }
  505. if showCheckmark && isSelected {
  506. // show checkmark to indicate if the preset was actually pressed
  507. Image(systemName: "checkmark.circle.fill")
  508. .imageScale(.large)
  509. .fontWeight(.bold)
  510. .foregroundStyle(Color.green)
  511. } else {
  512. Image(systemName: "line.3.horizontal")
  513. .imageScale(.medium)
  514. .foregroundStyle(.secondary)
  515. }
  516. })
  517. }
  518. private var overrideLabelDivider: some View {
  519. Divider()
  520. .frame(width: 1, height: 20)
  521. }
  522. @ViewBuilder private func overridesView(for preset: OverrideStored) -> some View {
  523. let isSelected = preset.id == selectedPresetID
  524. let name = preset.name ?? ""
  525. let indefinite = preset.indefinite
  526. let duration = preset.duration?.decimalValue ?? Decimal(0)
  527. let percentage = preset.percentage
  528. let smbMinutes = preset.smbMinutes?.decimalValue ?? Decimal(0)
  529. let uamMinutes = preset.uamMinutes?.decimalValue ?? Decimal(0)
  530. let target: String = {
  531. guard let targetValue = preset.target, targetValue != 0 else { return "" }
  532. return state.units == .mgdL ? targetValue.description : targetValue.decimalValue.formattedAsMmolL
  533. }()
  534. let targetString = target.isEmpty ? "" : "\(target) \(state.units.rawValue)"
  535. let durationString = indefinite ? "" : "\(formatHrMin(Int(duration)))"
  536. let scheduledSMBString: String = {
  537. guard preset.smbIsScheduledOff, preset.start != preset.end else { return "" }
  538. return " \(formatTimeRange(start: preset.start?.stringValue, end: preset.end?.stringValue))"
  539. }()
  540. let smbString: String = {
  541. guard preset.smbIsOff || preset.smbIsScheduledOff else { return "" }
  542. return "SMBs Off\(scheduledSMBString)"
  543. }()
  544. let maxSmbMinsString: String = {
  545. guard smbMinutes != 0, preset.advancedSettings, !preset.smbIsOff,
  546. smbMinutes != state.defaultSmbMinutes else { return "" }
  547. return "\(smbMinutes.formatted()) min SMB"
  548. }()
  549. let maxUamMinsString: String = {
  550. guard uamMinutes != 0, preset.advancedSettings, !preset.smbIsOff,
  551. uamMinutes != state.defaultUamMinutes else { return "" }
  552. return "\(uamMinutes.formatted()) min UAM"
  553. }()
  554. let isfAndCrString: String = {
  555. switch (preset.isfAndCr, preset.isf, preset.cr) {
  556. case (_, true, true),
  557. (true, _, _):
  558. return " ISF/CR"
  559. case (false, true, false):
  560. return " ISF"
  561. case (false, false, true):
  562. return " CR"
  563. default:
  564. return ""
  565. }
  566. }()
  567. let percentageString = percentage != 100 ? "\(Int(percentage))%\(isfAndCrString)" : ""
  568. // Combine all labels into a single array, filtering out empty strings
  569. let labels: [String] = [
  570. durationString,
  571. percentageString,
  572. targetString,
  573. smbString,
  574. maxSmbMinsString,
  575. maxUamMinsString
  576. ].filter { !$0.isEmpty }
  577. if !name.isEmpty {
  578. ZStack(alignment: .trailing) {
  579. HStack {
  580. VStack {
  581. HStack {
  582. Text(name)
  583. Spacer()
  584. }
  585. HStack(spacing: 5) {
  586. ForEach(labels, id: \.self) { label in
  587. Text(label)
  588. if label != labels.last { // Add divider between labels
  589. overrideLabelDivider
  590. }
  591. }
  592. Spacer()
  593. }
  594. .padding(.top, 2)
  595. .foregroundColor(.secondary)
  596. .font(.caption)
  597. }
  598. .contentShape(Rectangle())
  599. .onTapGesture {
  600. Task {
  601. let objectID = preset.objectID
  602. await state.enactOverridePreset(withID: objectID)
  603. state.hideModal()
  604. showCheckmark.toggle()
  605. selectedPresetID = preset.id
  606. // Deactivate checkmark after 3 seconds
  607. DispatchQueue.main.asyncAfter(deadline: .now() + 3) {
  608. showCheckmark = false
  609. }
  610. }
  611. }
  612. }
  613. // show checkmark to indicate if the preset was actually pressed
  614. if showCheckmark && isSelected {
  615. Image(systemName: "checkmark.circle.fill")
  616. .imageScale(.large)
  617. .fontWeight(.bold)
  618. .foregroundStyle(Color.green)
  619. } else {
  620. Image(systemName: "line.3.horizontal")
  621. .imageScale(.medium)
  622. .foregroundStyle(.secondary)
  623. }
  624. }
  625. }
  626. }
  627. }
  628. }