AdjustmentsRootView.swift 33 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776
  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. .safeAreaInset(edge: .bottom, spacing: 30) { stickyStopButton }
  118. .scrollContentBackground(.hidden).background(color)
  119. .onAppear(perform: configureView)
  120. .navigationBarTitle("Adjustments")
  121. .navigationBarTitleDisplayMode(.large)
  122. .toolbar {
  123. ToolbarItem(placement: .topBarTrailing) {
  124. switch state.selectedTab {
  125. case .overrides:
  126. Button(action: {
  127. showOverrideCreationSheet = true
  128. }, label: {
  129. HStack {
  130. Text("Add Override")
  131. Image(systemName: "plus")
  132. }
  133. })
  134. case .tempTargets:
  135. Button(action: {
  136. showTempTargetCreationSheet = true
  137. }, label: {
  138. HStack {
  139. Text("Add Temp Target")
  140. Image(systemName: "plus")
  141. }
  142. })
  143. }
  144. }
  145. }
  146. .sheet(isPresented: $state.showOverrideEditSheet, onDismiss: {
  147. Task {
  148. await state.resetStateVariables()
  149. state.showOverrideEditSheet = false
  150. }
  151. }) {
  152. if let override = selectedOverride {
  153. EditOverrideForm(overrideToEdit: override, state: state)
  154. }
  155. }
  156. .sheet(isPresented: $showOverrideCreationSheet, onDismiss: {
  157. Task {
  158. await state.resetStateVariables()
  159. showOverrideCreationSheet = false
  160. }
  161. }) {
  162. AddOverrideForm(state: state)
  163. }
  164. .sheet(isPresented: $showTempTargetCreationSheet, onDismiss: {
  165. Task {
  166. await state.resetTempTargetState()
  167. showTempTargetCreationSheet = false
  168. }
  169. }) {
  170. AddTempTargetForm(state: state)
  171. }
  172. .sheet(isPresented: $state.showTempTargetEditSheet, onDismiss: {
  173. Task {
  174. await state.resetTempTargetState()
  175. state.showTempTargetEditSheet = false
  176. }
  177. }) {
  178. if let tempTarget = selectedTempTarget {
  179. EditTempTargetForm(tempTargetToEdit: tempTarget, state: state)
  180. }
  181. }
  182. }.background(color)
  183. }
  184. @ViewBuilder func overrides() -> some View {
  185. if state.isEnabled, state.activeOverrideName.isNotEmpty {
  186. currentActiveAdjustment
  187. }
  188. if state.overridePresets.isNotEmpty {
  189. overridePresets
  190. } else {
  191. defaultText
  192. }
  193. }
  194. @ViewBuilder func tempTargets() -> some View {
  195. if state.isTempTargetEnabled, state.activeTempTargetName.isNotEmpty {
  196. currentActiveAdjustment
  197. }
  198. if state.scheduledTempTargets.isNotEmpty {
  199. scheduledTempTargets
  200. }
  201. if state.tempTargetPresets.isNotEmpty {
  202. tempTargetPresets
  203. } else {
  204. defaultText
  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. isConfirmDeletePresented = 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: $isConfirmDeletePresented,
  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 scheduledTempTargets: some View {
  297. Section {
  298. ForEach(state.scheduledTempTargets) { tempTarget in
  299. tempTargetView(for: tempTarget)
  300. .swipeActions(edge: .trailing, allowsFullSwipe: true) {
  301. swipeActions(for: tempTarget)
  302. }
  303. }
  304. .listRowBackground(Color.chart)
  305. } header: {
  306. Text("Scheduled Temp Targets")
  307. }
  308. }
  309. private var tempTargetPresets: some View {
  310. Section {
  311. ForEach(state.tempTargetPresets) { preset in
  312. tempTargetView(for: preset, showCheckmark: showCheckmark) {
  313. enactTempTargetPreset(preset)
  314. }
  315. .swipeActions(edge: .trailing, allowsFullSwipe: true) {
  316. swipeActions(for: preset)
  317. }
  318. }
  319. .onMove(perform: state.reorderTempTargets)
  320. .confirmationDialog(
  321. deleteConfirmationTitle,
  322. isPresented: $isConfirmDeletePresented,
  323. titleVisibility: .visible
  324. ) {
  325. deleteConfirmationButtons()
  326. } message: {
  327. deleteConfirmationMessage
  328. }
  329. .listRowBackground(Color.chart)
  330. } header: {
  331. Text("Presets")
  332. } footer: {
  333. HStack {
  334. Image(systemName: "hand.draw.fill")
  335. Text("Swipe left to edit or delete a Temp Target preset. Hold, drag and drop to reorder a preset.")
  336. }
  337. }
  338. }
  339. private func enactTempTargetPreset(_ preset: TempTargetStored) {
  340. Task {
  341. let objectID = preset.objectID
  342. await state.enactTempTargetPreset(withID: objectID)
  343. selectedTempTargetPresetID = preset.id?.uuidString
  344. showCheckmark.toggle()
  345. DispatchQueue.main.asyncAfter(deadline: .now() + 3) {
  346. showCheckmark = false
  347. }
  348. }
  349. }
  350. private func swipeActions(for tempTarget: TempTargetStored) -> some View {
  351. Group {
  352. Button {
  353. Task {
  354. selectedTempTarget = tempTarget
  355. isConfirmDeletePresented = true
  356. }
  357. } label: {
  358. Label("Delete", systemImage: "trash")
  359. .tint(.red)
  360. }
  361. Button(action: {
  362. selectedTempTarget = tempTarget
  363. state.showTempTargetEditSheet = true
  364. }, label: {
  365. Label("Edit", systemImage: "pencil")
  366. .tint(.blue)
  367. })
  368. }
  369. }
  370. private var deleteConfirmationTitle: String {
  371. "Delete the Temp Target Preset \"\(selectedTempTarget?.name ?? "")\"?"
  372. }
  373. private func deleteConfirmationButtons() -> some View {
  374. Group {
  375. if let itemToDelete = selectedTempTarget {
  376. Button(
  377. state.currentActiveTempTarget == selectedTempTarget ? "Stop and Delete" : "Delete",
  378. role: .destructive
  379. ) {
  380. if state.currentActiveTempTarget == selectedTempTarget {
  381. Task {
  382. await state.disableAllActiveTempTargets(createTempTargetRunEntry: true)
  383. }
  384. }
  385. Task {
  386. await state.invokeTempTargetPresetDeletion(itemToDelete.objectID)
  387. }
  388. selectedTempTarget = nil
  389. }
  390. }
  391. Button("Cancel", role: .cancel) {
  392. selectedTempTarget = nil
  393. }
  394. }
  395. }
  396. private var deleteConfirmationMessage: Text? {
  397. if state.currentActiveTempTarget == selectedTempTarget {
  398. return Text("This Temp Target preset is currently running. Deleting will stop it.")
  399. }
  400. return nil
  401. }
  402. private var currentActiveAdjustment: some View {
  403. switch state.selectedTab {
  404. case .overrides:
  405. Section {
  406. HStack {
  407. Text("\(state.activeOverrideName) is running")
  408. Spacer()
  409. Image(systemName: "square.and.pencil")
  410. .foregroundStyle(Color.blue)
  411. }
  412. .contentShape(Rectangle())
  413. .onTapGesture {
  414. Task {
  415. /// To avoid editing the Preset when a Preset-Override is running we first duplicate the Preset-Override as a non-Preset Override
  416. /// The currentActiveOverride variable in the State will update automatically via MOC notification
  417. await state.duplicateOverridePresetAndCancelPreviousOverride()
  418. /// selectedOverride is used for passing the chosen Override to the EditSheet so we have to set the updated currentActiveOverride to be the selectedOverride
  419. selectedOverride = state.currentActiveOverride
  420. /// Now we can show the Edit sheet
  421. state.showOverrideEditSheet = true
  422. }
  423. }
  424. }
  425. .listRowBackground(Color.blue.opacity(0.2))
  426. case .tempTargets:
  427. Section {
  428. HStack {
  429. Text("\(state.activeTempTargetName) is running")
  430. Spacer()
  431. Image(systemName: "square.and.pencil")
  432. .foregroundStyle(Color.blue)
  433. }
  434. .contentShape(Rectangle())
  435. .onTapGesture {
  436. Task {
  437. /// To avoid editing the Preset when a Preset-Override is running we first duplicate the Preset-Override as a non-Preset Override
  438. /// The currentActiveOverride variable in the State will update automatically via MOC notification
  439. await state.duplicateTempTargetPresetAndCancelPreviousTempTarget()
  440. /// selectedOverride is used for passing the chosen Override to the EditSheet so we have to set the updated currentActiveOverride to be the selectedOverride
  441. selectedTempTarget = state.currentActiveTempTarget
  442. /// Now we can show the Edit sheet
  443. state.showTempTargetEditSheet = true
  444. }
  445. }
  446. }
  447. .listRowBackground(Color.blue.opacity(0.2))
  448. }
  449. }
  450. var stickyStopButton: some View {
  451. ZStack {
  452. Rectangle()
  453. .frame(width: UIScreen.main.bounds.width, height: 65)
  454. .foregroundStyle(colorScheme == .dark ? Color.bgDarkerDarkBlue : Color.white)
  455. .background(.thinMaterial)
  456. .opacity(0.8)
  457. .clipShape(Rectangle())
  458. Group {
  459. switch state.selectedTab {
  460. case .overrides:
  461. Button(action: {
  462. Task {
  463. // Save cancelled Override in OverrideRunStored Entity
  464. // Cancel ALL active Override
  465. await state.disableAllActiveOverrides(createOverrideRunEntry: true)
  466. }
  467. }, label: {
  468. Text("Stop Override")
  469. .padding(10)
  470. })
  471. .frame(width: UIScreen.main.bounds.width * 0.9, alignment: .center)
  472. .disabled(!state.isEnabled)
  473. .background(!state.isEnabled ? Color(.systemGray4) : Color(.systemRed))
  474. .tint(.white)
  475. .clipShape(RoundedRectangle(cornerRadius: 8))
  476. case .tempTargets:
  477. Button(action: {
  478. Task {
  479. // Save cancelled Temp Targets in TempTargetRunStored Entity
  480. // Cancel ALL active Temp Targets
  481. await state.disableAllActiveTempTargets(createTempTargetRunEntry: true)
  482. // Update View
  483. state.updateLatestTempTargetConfiguration()
  484. }
  485. }, label: {
  486. Text("Stop Temp Target")
  487. .padding(10)
  488. })
  489. .frame(width: UIScreen.main.bounds.width * 0.9, alignment: .center)
  490. .disabled(!state.isTempTargetEnabled)
  491. .background(!state.isTempTargetEnabled ? Color(.systemGray4) : Color(.systemRed))
  492. .tint(.white)
  493. .clipShape(RoundedRectangle(cornerRadius: 8))
  494. }
  495. }.padding(5)
  496. }
  497. }
  498. private var cancelAdjustmentButton: some View {
  499. switch state.selectedTab {
  500. case .overrides:
  501. Button(action: {
  502. Task {
  503. // Save cancelled Override in OverrideRunStored Entity
  504. // Cancel ALL active Override
  505. await state.disableAllActiveOverrides(createOverrideRunEntry: true)
  506. }
  507. }, label: {
  508. Text("Stop Override")
  509. })
  510. .frame(maxWidth: .infinity, alignment: .center)
  511. .disabled(!state.isEnabled)
  512. .listRowBackground(!state.isEnabled ? Color(.systemGray4) : Color(.systemRed))
  513. .tint(.white)
  514. case .tempTargets:
  515. Button(action: {
  516. Task {
  517. // Save cancelled Temp Targets in TempTargetRunStored Entity
  518. // Cancel ALL active Temp Targets
  519. await state.disableAllActiveTempTargets(createTempTargetRunEntry: true)
  520. // Update View
  521. state.updateLatestTempTargetConfiguration()
  522. }
  523. }, label: {
  524. Text("Stop Temp Target")
  525. })
  526. .frame(maxWidth: .infinity, alignment: .center)
  527. .disabled(!state.isTempTargetEnabled)
  528. .listRowBackground(!state.isTempTargetEnabled ? Color(.systemGray4) : Color(.systemRed))
  529. .tint(.white)
  530. }
  531. }
  532. private func tempTargetView(
  533. for tempTarget: TempTargetStored,
  534. showCheckmark: Bool = false,
  535. onTap: (() -> Void)? = nil
  536. ) -> some View {
  537. let target = tempTarget.target ?? 100
  538. let tempTargetValue = Decimal(target as! Double.RawValue)
  539. let isSelected = tempTarget.id?.uuidString == selectedPresetID
  540. let tempTargetHalfBasal = Decimal(
  541. tempTarget.halfBasalTarget as? Double
  542. .RawValue ?? Double(state.settingHalfBasalTarget)
  543. )
  544. let percentage = Int(
  545. state.computeAdjustedPercentage(usingHBT: tempTargetHalfBasal, usingTarget: tempTargetValue)
  546. )
  547. let remainingTime = tempTarget.date?.timeIntervalSinceNow ?? 0
  548. return ZStack(alignment: .trailing) {
  549. HStack {
  550. VStack(alignment: .leading) {
  551. HStack {
  552. Text(tempTarget.name ?? "")
  553. Spacer()
  554. if remainingTime > 0 {
  555. Text("Starts in \(formattedTimeRemaining(remainingTime))")
  556. .foregroundColor(colorScheme == .dark ? .orange : .accentColor)
  557. }
  558. }
  559. HStack(spacing: 2) {
  560. Text(formattedGlucose(glucose: target as Decimal))
  561. .foregroundColor(.secondary)
  562. .font(.caption)
  563. Text("for")
  564. .foregroundColor(.secondary)
  565. .font(.caption)
  566. Text("\(formatter.string(from: (tempTarget.duration ?? 0) as NSNumber)!)")
  567. .foregroundColor(.secondary)
  568. .font(.caption)
  569. Text("min")
  570. .foregroundColor(.secondary)
  571. .font(.caption)
  572. if state.isAdjustSensEnabled(usingTarget: tempTargetValue) {
  573. Text(", \(percentage)%")
  574. .foregroundColor(.secondary)
  575. .font(.caption)
  576. }
  577. Spacer()
  578. }
  579. .padding(.top, 2)
  580. }
  581. .contentShape(Rectangle())
  582. .onTapGesture {
  583. onTap?()
  584. }
  585. }
  586. if showCheckmark && isSelected {
  587. Image(systemName: "checkmark.circle.fill")
  588. .imageScale(.large)
  589. .fontWeight(.bold)
  590. .foregroundStyle(Color.green)
  591. } else if onTap != nil {
  592. Image(systemName: "line.3.horizontal")
  593. .imageScale(.medium)
  594. .foregroundStyle(.secondary)
  595. }
  596. }
  597. }
  598. private func formattedTimeRemaining(_ timeInterval: TimeInterval) -> String {
  599. let totalSeconds = Int(timeInterval)
  600. let hours = totalSeconds / 3600
  601. let minutes = (totalSeconds % 3600) / 60
  602. let seconds = totalSeconds % 60
  603. if hours > 0 {
  604. return "\(hours)h \(minutes)m \(seconds)s"
  605. } else if minutes > 0 {
  606. return "\(minutes)m \(seconds)s"
  607. } else {
  608. return "<1m"
  609. }
  610. }
  611. private var overrideLabelDivider: some View {
  612. Divider()
  613. .frame(width: 1, height: 20)
  614. }
  615. @ViewBuilder private func overridesView(for preset: OverrideStored) -> some View {
  616. let isSelected = preset.id == selectedPresetID
  617. let name = preset.name ?? ""
  618. let indefinite = preset.indefinite
  619. let duration = preset.duration?.decimalValue ?? Decimal(0)
  620. let percentage = preset.percentage
  621. let smbMinutes = preset.smbMinutes?.decimalValue ?? Decimal(0)
  622. let uamMinutes = preset.uamMinutes?.decimalValue ?? Decimal(0)
  623. let target: String = {
  624. guard let targetValue = preset.target, targetValue != 0 else { return "" }
  625. return state.units == .mgdL ? targetValue.description : targetValue.decimalValue.formattedAsMmolL
  626. }()
  627. let targetString = target.isEmpty ? "" : "\(target) \(state.units.rawValue)"
  628. let durationString = indefinite ? "" : "\(formatHrMin(Int(duration)))"
  629. let scheduledSMBString: String = {
  630. guard preset.smbIsScheduledOff, preset.start != preset.end else { return "" }
  631. return " \(formatTimeRange(start: preset.start?.stringValue, end: preset.end?.stringValue))"
  632. }()
  633. let smbString: String = {
  634. guard preset.smbIsOff || preset.smbIsScheduledOff else { return "" }
  635. return "SMBs Off\(scheduledSMBString)"
  636. }()
  637. let maxSmbMinsString: String = {
  638. guard smbMinutes != 0, preset.advancedSettings, !preset.smbIsOff,
  639. smbMinutes != state.defaultSmbMinutes else { return "" }
  640. return "\(smbMinutes.formatted()) min SMB"
  641. }()
  642. let maxUamMinsString: String = {
  643. guard uamMinutes != 0, preset.advancedSettings, !preset.smbIsOff,
  644. uamMinutes != state.defaultUamMinutes else { return "" }
  645. return "\(uamMinutes.formatted()) min UAM"
  646. }()
  647. let isfAndCrString: String = {
  648. switch (preset.isfAndCr, preset.isf, preset.cr) {
  649. case (_, true, true),
  650. (true, _, _):
  651. return " ISF/CR"
  652. case (false, true, false):
  653. return " ISF"
  654. case (false, false, true):
  655. return " CR"
  656. default:
  657. return ""
  658. }
  659. }()
  660. let percentageString = percentage != 100 ? "\(Int(percentage))%\(isfAndCrString)" : ""
  661. // Combine all labels into a single array, filtering out empty strings
  662. let labels: [String] = [
  663. durationString,
  664. percentageString,
  665. targetString,
  666. smbString,
  667. maxSmbMinsString,
  668. maxUamMinsString
  669. ].filter { !$0.isEmpty }
  670. if !name.isEmpty {
  671. ZStack(alignment: .trailing) {
  672. HStack {
  673. VStack {
  674. HStack {
  675. Text(name)
  676. Spacer()
  677. }
  678. HStack(spacing: 5) {
  679. ForEach(labels, id: \.self) { label in
  680. Text(label)
  681. if label != labels.last { // Add divider between labels
  682. overrideLabelDivider
  683. }
  684. }
  685. Spacer()
  686. }
  687. .padding(.top, 2)
  688. .foregroundColor(.secondary)
  689. .font(.caption)
  690. }
  691. .contentShape(Rectangle())
  692. .onTapGesture {
  693. Task {
  694. let objectID = preset.objectID
  695. await state.enactOverridePreset(withID: objectID)
  696. state.hideModal()
  697. showCheckmark.toggle()
  698. selectedPresetID = preset.id
  699. // Deactivate checkmark after 3 seconds
  700. DispatchQueue.main.asyncAfter(deadline: .now() + 3) {
  701. showCheckmark = false
  702. }
  703. }
  704. }
  705. }
  706. // show checkmark to indicate if the preset was actually pressed
  707. if showCheckmark && isSelected {
  708. Image(systemName: "checkmark.circle.fill")
  709. .imageScale(.large)
  710. .fontWeight(.bold)
  711. .foregroundStyle(Color.green)
  712. } else {
  713. Image(systemName: "line.3.horizontal")
  714. .imageScale(.medium)
  715. .foregroundStyle(.secondary)
  716. }
  717. }
  718. }
  719. }
  720. }
  721. }