AdjustmentsRootView.swift 31 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740
  1. import CoreData
  2. import SwiftUI
  3. import Swinject
  4. extension Adjustments {
  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. @State private var isConfirmDeletePresented = false
  18. @State private var isPromptPresented = false
  19. @State private var isRemoveAlertPresented = false
  20. @State private var removeAlert: Alert?
  21. @State private var isEditingTT = false
  22. @Environment(\.colorScheme) var colorScheme
  23. var color: LinearGradient {
  24. colorScheme == .dark ? LinearGradient(
  25. gradient: Gradient(colors: [
  26. Color.bgDarkBlue,
  27. Color.bgDarkerDarkBlue
  28. ]),
  29. startPoint: .top,
  30. endPoint: .bottom
  31. )
  32. :
  33. LinearGradient(
  34. gradient: Gradient(colors: [Color.gray.opacity(0.1)]),
  35. startPoint: .top,
  36. endPoint: .bottom
  37. )
  38. }
  39. private var formatter: NumberFormatter {
  40. let formatter = NumberFormatter()
  41. formatter.numberStyle = .decimal
  42. formatter.maximumFractionDigits = 0
  43. return formatter
  44. }
  45. private var glucoseFormatter: NumberFormatter {
  46. let formatter = NumberFormatter()
  47. formatter.numberStyle = .decimal
  48. formatter.maximumFractionDigits = 0
  49. if state.units == .mmolL {
  50. formatter.maximumFractionDigits = 1
  51. }
  52. formatter.roundingMode = .halfUp
  53. return formatter
  54. }
  55. private func formattedGlucose(glucose: Decimal) -> String {
  56. let formattedValue: String
  57. if state.units == .mgdL {
  58. formattedValue = glucoseFormatter.string(from: glucose as NSDecimalNumber) ?? "\(glucose)"
  59. } else {
  60. formattedValue = glucose.formattedAsMmolL
  61. }
  62. return "\(formattedValue) \(state.units.rawValue)"
  63. }
  64. var body: some View {
  65. ZStack(alignment: .center, content: {
  66. VStack {
  67. Picker("Adjustment Tabs", selection: $state.selectedTab) {
  68. ForEach(Adjustments.Tab.allCases.indexed(), id: \.1) { index, item in
  69. Text(item.name).tag(index)
  70. }
  71. }
  72. .pickerStyle(SegmentedPickerStyle())
  73. .padding(.horizontal)
  74. List {
  75. switch state.selectedTab {
  76. case .overrides: overrides()
  77. case .tempTargets: tempTargets() }
  78. }
  79. .scrollContentBackground(.hidden)
  80. .background(color)
  81. }
  82. .listSectionSpacing(10)
  83. .safeAreaInset(edge: .bottom, spacing: 30) { stickyStopButton }
  84. .scrollContentBackground(.hidden).background(color)
  85. .onAppear(perform: configureView)
  86. .navigationBarTitle("Adjustments")
  87. .navigationBarTitleDisplayMode(.large)
  88. .toolbar {
  89. ToolbarItem(placement: .topBarTrailing) {
  90. switch state.selectedTab {
  91. case .overrides:
  92. Button(action: {
  93. showOverrideCreationSheet = true
  94. }, label: {
  95. HStack {
  96. Text("Add Override")
  97. Image(systemName: "plus")
  98. }
  99. })
  100. case .tempTargets:
  101. Button(action: {
  102. showTempTargetCreationSheet = true
  103. }, label: {
  104. HStack {
  105. Text("Add Temp Target")
  106. Image(systemName: "plus")
  107. }
  108. })
  109. }
  110. }
  111. }
  112. .sheet(isPresented: $state.showOverrideEditSheet, onDismiss: {
  113. Task {
  114. await state.resetStateVariables()
  115. state.showOverrideEditSheet = false
  116. }
  117. }) {
  118. if let override = selectedOverride {
  119. EditOverrideForm(overrideToEdit: override, state: state)
  120. }
  121. }
  122. .sheet(isPresented: $showOverrideCreationSheet, onDismiss: {
  123. Task {
  124. await state.resetStateVariables()
  125. showOverrideCreationSheet = false
  126. }
  127. }) {
  128. AddOverrideForm(state: state)
  129. }
  130. .sheet(isPresented: $showTempTargetCreationSheet, onDismiss: {
  131. Task {
  132. await state.resetTempTargetState()
  133. showTempTargetCreationSheet = false
  134. }
  135. }) {
  136. AddTempTargetForm(state: state)
  137. }
  138. .sheet(isPresented: $state.showTempTargetEditSheet, onDismiss: {
  139. Task {
  140. await state.resetTempTargetState()
  141. state.showTempTargetEditSheet = false
  142. }
  143. }) {
  144. if let tempTarget = selectedTempTarget {
  145. EditTempTargetForm(tempTargetToEdit: tempTarget, state: state)
  146. }
  147. }
  148. }).background(color)
  149. }
  150. @ViewBuilder func overrides() -> some View {
  151. if state.isEnabled, state.activeOverrideName.isNotEmpty {
  152. currentActiveAdjustment
  153. }
  154. if state.overridePresets.isNotEmpty {
  155. overridePresets
  156. } else {
  157. defaultText
  158. }
  159. }
  160. @ViewBuilder func tempTargets() -> some View {
  161. if state.isTempTargetEnabled, state.activeTempTargetName.isNotEmpty {
  162. currentActiveAdjustment
  163. }
  164. if state.scheduledTempTargets.isNotEmpty {
  165. scheduledTempTargets
  166. }
  167. if state.tempTargetPresets.isNotEmpty {
  168. tempTargetPresets
  169. } else {
  170. defaultText
  171. }
  172. }
  173. private var defaultText: some View {
  174. switch state.selectedTab {
  175. case .overrides:
  176. Section {} header: {
  177. Text("Add Preset or Override by tapping 'Add Override +' in the top right-hand corner of the screen.")
  178. .textCase(nil)
  179. .foregroundStyle(.secondary)
  180. }
  181. case .tempTargets:
  182. Section {} header: {
  183. Text(
  184. "Add Preset or Temp Target by tapping 'Add Temp Target +' in the top right-hand corner of the screen."
  185. )
  186. .textCase(nil)
  187. .foregroundStyle(.secondary)
  188. }
  189. }
  190. }
  191. private var overridePresets: some View {
  192. Section {
  193. ForEach(state.overridePresets) { preset in
  194. overridesView(for: preset)
  195. .swipeActions(edge: .trailing, allowsFullSwipe: true) {
  196. Button(role: .none) {
  197. selectedOverride = preset
  198. isConfirmDeletePresented = true
  199. } label: {
  200. Label("Delete", systemImage: "trash")
  201. .tint(.red)
  202. }
  203. Button(action: {
  204. // Set the selected Override to the chosen Preset and pass it to the Edit Sheet
  205. selectedOverride = preset
  206. state.showOverrideEditSheet = true
  207. }, label: {
  208. Label("Edit", systemImage: "pencil")
  209. .tint(.blue)
  210. })
  211. }
  212. }
  213. .onMove(perform: state.reorderOverride)
  214. .confirmationDialog(
  215. "Delete the Override Preset \"\(selectedOverride?.name ?? "")\"?",
  216. isPresented: $isConfirmDeletePresented,
  217. titleVisibility: .visible
  218. ) {
  219. if let itemToDelete = selectedOverride {
  220. Button(
  221. state.currentActiveOverride == selectedOverride ? "Stop and Delete" : "Delete",
  222. role: .destructive
  223. ) {
  224. if state.currentActiveOverride == selectedOverride {
  225. Task {
  226. // Save cancelled Override in OverrideRunStored Entity
  227. // Cancel ALL active Override
  228. await state.disableAllActiveOverrides(createOverrideRunEntry: true)
  229. }
  230. }
  231. // Perform the delete action
  232. Task {
  233. await state.invokeOverridePresetDeletion(itemToDelete.objectID)
  234. }
  235. // Reset the selected item after deletion
  236. selectedOverride = nil
  237. }
  238. }
  239. Button("Cancel", role: .cancel) {
  240. // Dismiss the dialog without action
  241. selectedOverride = nil
  242. }
  243. } message: {
  244. if state.currentActiveOverride == selectedOverride {
  245. Text(
  246. state
  247. .currentActiveOverride == selectedOverride ?
  248. "This override preset is currently running. Deleting will stop it." : ""
  249. )
  250. }
  251. }
  252. .listRowBackground(Color.chart)
  253. } header: {
  254. Text("Override Presets")
  255. } footer: {
  256. HStack {
  257. Image(systemName: "hand.draw.fill").foregroundStyle(.primary)
  258. Text("Swipe left to edit or delete an override preset. Hold, drag and drop to reorder a preset.")
  259. }
  260. }
  261. }
  262. private var scheduledTempTargets: some View {
  263. Section {
  264. ForEach(state.scheduledTempTargets) { tempTarget in
  265. tempTargetView(for: tempTarget)
  266. .swipeActions(edge: .trailing, allowsFullSwipe: true) {
  267. swipeActions(for: tempTarget)
  268. }
  269. }
  270. .listRowBackground(Color.chart)
  271. } header: {
  272. Text("Scheduled Temp Targets")
  273. }
  274. }
  275. private var tempTargetPresets: some View {
  276. Section {
  277. ForEach(state.tempTargetPresets) { preset in
  278. tempTargetView(for: preset, showCheckmark: showCheckmark) {
  279. enactTempTargetPreset(preset)
  280. }
  281. .swipeActions(edge: .trailing, allowsFullSwipe: true) {
  282. swipeActions(for: preset)
  283. }
  284. }
  285. .onMove(perform: state.reorderTempTargets)
  286. .confirmationDialog(
  287. deleteConfirmationTitle,
  288. isPresented: $isConfirmDeletePresented,
  289. titleVisibility: .visible
  290. ) {
  291. deleteConfirmationButtons()
  292. } message: {
  293. deleteConfirmationMessage
  294. }
  295. .listRowBackground(Color.chart)
  296. } header: {
  297. Text("Temporary Target Presets")
  298. } footer: {
  299. HStack {
  300. Image(systemName: "hand.draw.fill").foregroundStyle(.primary)
  301. Text("Swipe left to edit or delete a temporary target preset. Hold, drag and drop to reorder a preset.")
  302. }
  303. }
  304. }
  305. private func enactTempTargetPreset(_ preset: TempTargetStored) {
  306. Task {
  307. let objectID = preset.objectID
  308. await state.enactTempTargetPreset(withID: objectID)
  309. selectedTempTargetPresetID = preset.id?.uuidString
  310. showCheckmark.toggle()
  311. DispatchQueue.main.asyncAfter(deadline: .now() + 3) {
  312. showCheckmark = false
  313. }
  314. }
  315. }
  316. private func swipeActions(for tempTarget: TempTargetStored) -> some View {
  317. Group {
  318. Button {
  319. Task {
  320. selectedTempTarget = tempTarget
  321. isConfirmDeletePresented = true
  322. }
  323. } label: {
  324. Label("Delete", systemImage: "trash")
  325. .tint(.red)
  326. }
  327. Button(action: {
  328. selectedTempTarget = tempTarget
  329. state.showTempTargetEditSheet = true
  330. }, label: {
  331. Label("Edit", systemImage: "pencil")
  332. .tint(.blue)
  333. })
  334. }
  335. }
  336. private var deleteConfirmationTitle: String {
  337. "Delete the Temp Target Preset \"\(selectedTempTarget?.name ?? "")\"?"
  338. }
  339. private func deleteConfirmationButtons() -> some View {
  340. Group {
  341. if let itemToDelete = selectedTempTarget {
  342. Button(
  343. state.currentActiveTempTarget == selectedTempTarget ? "Stop and Delete" : "Delete",
  344. role: .destructive
  345. ) {
  346. if state.currentActiveTempTarget == selectedTempTarget {
  347. Task {
  348. await state.disableAllActiveTempTargets(createTempTargetRunEntry: true)
  349. }
  350. }
  351. Task {
  352. await state.invokeTempTargetPresetDeletion(itemToDelete.objectID)
  353. }
  354. selectedTempTarget = nil
  355. }
  356. }
  357. Button("Cancel", role: .cancel) {
  358. selectedTempTarget = nil
  359. }
  360. }
  361. }
  362. private var deleteConfirmationMessage: Text? {
  363. if state.currentActiveTempTarget == selectedTempTarget {
  364. return Text("This Temp Target preset is currently running. Deleting will stop it.")
  365. }
  366. return nil
  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.primary)
  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.purple.opacity(0.8))
  392. case .tempTargets:
  393. Section {
  394. HStack {
  395. Text("\(state.activeTempTargetName) is running")
  396. Spacer()
  397. Image(systemName: "square.and.pencil")
  398. .foregroundStyle(Color.primary)
  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.loopGreen.opacity(0.8))
  414. }
  415. }
  416. var stickyStopButton: some View {
  417. ZStack {
  418. Rectangle()
  419. .frame(width: UIScreen.main.bounds.width, height: 65)
  420. .foregroundStyle(colorScheme == .dark ? Color.bgDarkerDarkBlue : Color.white)
  421. .background(.thinMaterial)
  422. .opacity(0.8)
  423. .clipShape(Rectangle())
  424. Group {
  425. switch state.selectedTab {
  426. case .overrides:
  427. Button(action: {
  428. Task {
  429. // Save cancelled Override in OverrideRunStored Entity
  430. // Cancel ALL active Override
  431. await state.disableAllActiveOverrides(createOverrideRunEntry: true)
  432. }
  433. }, label: {
  434. Text("Stop Override")
  435. .padding(10)
  436. })
  437. .frame(width: UIScreen.main.bounds.width * 0.9, alignment: .center)
  438. .disabled(!state.isEnabled)
  439. .background(!state.isEnabled ? Color(.systemGray4) : Color(.systemRed))
  440. .tint(.white)
  441. .clipShape(RoundedRectangle(cornerRadius: 8))
  442. case .tempTargets:
  443. Button(action: {
  444. Task {
  445. // Save cancelled Temp Targets in TempTargetRunStored Entity
  446. // Cancel ALL active Temp Targets
  447. await state.disableAllActiveTempTargets(createTempTargetRunEntry: true)
  448. // Update View
  449. state.updateLatestTempTargetConfiguration()
  450. }
  451. }, label: {
  452. Text("Stop Temp Target")
  453. .padding(10)
  454. })
  455. .frame(width: UIScreen.main.bounds.width * 0.9, alignment: .center)
  456. .disabled(!state.isTempTargetEnabled)
  457. .background(!state.isTempTargetEnabled ? Color(.systemGray4) : Color(.systemRed))
  458. .tint(.white)
  459. .clipShape(RoundedRectangle(cornerRadius: 8))
  460. }
  461. }.padding(5)
  462. }
  463. }
  464. private var cancelAdjustmentButton: some View {
  465. switch state.selectedTab {
  466. case .overrides:
  467. Button(action: {
  468. Task {
  469. // Save cancelled Override in OverrideRunStored Entity
  470. // Cancel ALL active Override
  471. await state.disableAllActiveOverrides(createOverrideRunEntry: true)
  472. }
  473. }, label: {
  474. Text("Stop Override")
  475. })
  476. .frame(maxWidth: .infinity, alignment: .center)
  477. .disabled(!state.isEnabled)
  478. .listRowBackground(!state.isEnabled ? Color(.systemGray4) : Color(.systemRed))
  479. .tint(.white)
  480. case .tempTargets:
  481. Button(action: {
  482. Task {
  483. // Save cancelled Temp Targets in TempTargetRunStored Entity
  484. // Cancel ALL active Temp Targets
  485. await state.disableAllActiveTempTargets(createTempTargetRunEntry: true)
  486. // Update View
  487. state.updateLatestTempTargetConfiguration()
  488. }
  489. }, label: {
  490. Text("Stop Temp Target")
  491. })
  492. .frame(maxWidth: .infinity, alignment: .center)
  493. .disabled(!state.isTempTargetEnabled)
  494. .listRowBackground(!state.isTempTargetEnabled ? Color(.systemGray4) : Color(.systemRed))
  495. .tint(.white)
  496. }
  497. }
  498. private func tempTargetView(
  499. for tempTarget: TempTargetStored,
  500. showCheckmark: Bool = false,
  501. onTap: (() -> Void)? = nil
  502. ) -> some View {
  503. let target = tempTarget.target ?? 100
  504. let tempTargetValue = Decimal(target as! Double.RawValue)
  505. let isSelected = tempTarget.id?.uuidString == selectedPresetID
  506. let tempTargetHalfBasal = Decimal(
  507. tempTarget.halfBasalTarget as? Double
  508. .RawValue ?? Double(state.settingHalfBasalTarget)
  509. )
  510. let percentage = Int(
  511. state.computeAdjustedPercentage(usingHBT: tempTargetHalfBasal, usingTarget: tempTargetValue)
  512. )
  513. let remainingTime = tempTarget.date?.timeIntervalSinceNow ?? 0
  514. return ZStack(alignment: .trailing) {
  515. HStack {
  516. VStack(alignment: .leading) {
  517. HStack {
  518. Text(tempTarget.name ?? "")
  519. Spacer()
  520. if remainingTime > 0 {
  521. Text("Starts in \(formattedTimeRemaining(remainingTime))")
  522. .foregroundColor(colorScheme == .dark ? .orange : .accentColor)
  523. }
  524. }
  525. HStack(spacing: 2) {
  526. Text(formattedGlucose(glucose: target as Decimal))
  527. .foregroundColor(.secondary)
  528. .font(.caption)
  529. Text("for")
  530. .foregroundColor(.secondary)
  531. .font(.caption)
  532. Text("\(formatter.string(from: (tempTarget.duration ?? 0) as NSNumber)!)")
  533. .foregroundColor(.secondary)
  534. .font(.caption)
  535. Text("min")
  536. .foregroundColor(.secondary)
  537. .font(.caption)
  538. if state.isAdjustSensEnabled(usingTarget: tempTargetValue) {
  539. Text(", \(percentage)%")
  540. .foregroundColor(.secondary)
  541. .font(.caption)
  542. }
  543. Spacer()
  544. }
  545. .padding(.top, 2)
  546. }
  547. .contentShape(Rectangle())
  548. .onTapGesture {
  549. onTap?()
  550. }
  551. }
  552. if showCheckmark && isSelected {
  553. Image(systemName: "checkmark.circle.fill")
  554. .imageScale(.large)
  555. .fontWeight(.bold)
  556. .foregroundStyle(Color.green)
  557. } else if onTap != nil {
  558. Image(systemName: "line.3.horizontal")
  559. .imageScale(.medium)
  560. .foregroundStyle(.secondary)
  561. }
  562. }
  563. }
  564. private func formattedTimeRemaining(_ timeInterval: TimeInterval) -> String {
  565. let totalSeconds = Int(timeInterval)
  566. let hours = totalSeconds / 3600
  567. let minutes = (totalSeconds % 3600) / 60
  568. let seconds = totalSeconds % 60
  569. if hours > 0 {
  570. return "\(hours)h \(minutes)m \(seconds)s"
  571. } else if minutes > 0 {
  572. return "\(minutes)m \(seconds)s"
  573. } else {
  574. return "<1m"
  575. }
  576. }
  577. private var overrideLabelDivider: some View {
  578. Divider()
  579. .frame(width: 1, height: 20)
  580. }
  581. @ViewBuilder private func overridesView(for preset: OverrideStored) -> some View {
  582. let isSelected = preset.id == selectedPresetID
  583. let name = preset.name ?? ""
  584. let indefinite = preset.indefinite
  585. let duration = preset.duration?.decimalValue ?? Decimal(0)
  586. let percentage = preset.percentage
  587. let smbMinutes = preset.smbMinutes?.decimalValue ?? Decimal(0)
  588. let uamMinutes = preset.uamMinutes?.decimalValue ?? Decimal(0)
  589. let target: String = {
  590. guard let targetValue = preset.target, targetValue != 0 else { return "" }
  591. return state.units == .mgdL ? targetValue.description : targetValue.decimalValue.formattedAsMmolL
  592. }()
  593. let targetString = target.isEmpty ? "" : "\(target) \(state.units.rawValue)"
  594. let durationString = indefinite ? "" : "\(formatHrMin(Int(duration)))"
  595. let scheduledSMBString: String = {
  596. guard preset.smbIsScheduledOff, preset.start != preset.end else { return "" }
  597. return " \(formatTimeRange(start: preset.start?.stringValue, end: preset.end?.stringValue))"
  598. }()
  599. let smbString: String = {
  600. guard preset.smbIsOff || preset.smbIsScheduledOff else { return "" }
  601. return "SMBs Off\(scheduledSMBString)"
  602. }()
  603. let maxSmbMinsString: String = {
  604. guard smbMinutes != 0, preset.advancedSettings, !preset.smbIsOff,
  605. smbMinutes != state.defaultSmbMinutes else { return "" }
  606. return "\(smbMinutes.formatted()) min SMB"
  607. }()
  608. let maxUamMinsString: String = {
  609. guard uamMinutes != 0, preset.advancedSettings, !preset.smbIsOff,
  610. uamMinutes != state.defaultUamMinutes else { return "" }
  611. return "\(uamMinutes.formatted()) min UAM"
  612. }()
  613. let isfAndCrString: String = {
  614. switch (preset.isfAndCr, preset.isf, preset.cr) {
  615. case (_, true, true),
  616. (true, _, _):
  617. return " ISF/CR"
  618. case (false, true, false):
  619. return " ISF"
  620. case (false, false, true):
  621. return " CR"
  622. default:
  623. return ""
  624. }
  625. }()
  626. let percentageString = percentage != 100 ? "\(Int(percentage))%\(isfAndCrString)" : ""
  627. // Combine all labels into a single array, filtering out empty strings
  628. let labels: [String] = [
  629. durationString,
  630. percentageString,
  631. targetString,
  632. smbString,
  633. maxSmbMinsString,
  634. maxUamMinsString
  635. ].filter { !$0.isEmpty }
  636. if !name.isEmpty {
  637. ZStack(alignment: .trailing) {
  638. HStack {
  639. VStack {
  640. HStack {
  641. Text(name)
  642. Spacer()
  643. }
  644. HStack(spacing: 5) {
  645. ForEach(labels, id: \.self) { label in
  646. Text(label)
  647. if label != labels.last { // Add divider between labels
  648. overrideLabelDivider
  649. }
  650. }
  651. Spacer()
  652. }
  653. .padding(.top, 2)
  654. .foregroundColor(.secondary)
  655. .font(.caption)
  656. }
  657. .contentShape(Rectangle())
  658. .onTapGesture {
  659. Task {
  660. let objectID = preset.objectID
  661. await state.enactOverridePreset(withID: objectID)
  662. state.hideModal()
  663. showCheckmark.toggle()
  664. selectedPresetID = preset.id
  665. // Deactivate checkmark after 3 seconds
  666. DispatchQueue.main.asyncAfter(deadline: .now() + 3) {
  667. showCheckmark = false
  668. }
  669. }
  670. }
  671. }
  672. // show checkmark to indicate if the preset was actually pressed
  673. if showCheckmark && isSelected {
  674. Image(systemName: "checkmark.circle.fill")
  675. .imageScale(.large)
  676. .fontWeight(.bold)
  677. .foregroundStyle(Color.green)
  678. } else {
  679. Image(systemName: "line.3.horizontal")
  680. .imageScale(.medium)
  681. .foregroundStyle(.secondary)
  682. }
  683. }
  684. }
  685. }
  686. }
  687. }