AdjustmentsRootView.swift 30 KB

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