AdjustmentsRootView.swift 32 KB

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