AdjustmentsRootView.swift 30 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708
  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. }, label: {
  405. Text("Stop Override")
  406. .padding(10)
  407. })
  408. .frame(width: UIScreen.main.bounds.width * 0.9, alignment: .center)
  409. .disabled(!state.isEnabled)
  410. .background(!state.isEnabled ? Color(.systemGray4) : Color(.systemRed))
  411. .tint(.white)
  412. .clipShape(RoundedRectangle(cornerRadius: 8))
  413. case .tempTargets:
  414. Button(action: {
  415. Task {
  416. // Save cancelled Temp Targets in TempTargetRunStored Entity
  417. // Cancel ALL active Temp Targets
  418. await state.disableAllActiveTempTargets(createTempTargetRunEntry: true)
  419. // Update View
  420. state.updateLatestTempTargetConfiguration()
  421. }
  422. }, label: {
  423. Text("Stop Temp Target")
  424. .padding(10)
  425. })
  426. .frame(width: UIScreen.main.bounds.width * 0.9, alignment: .center)
  427. .disabled(!state.isTempTargetEnabled)
  428. .background(!state.isTempTargetEnabled ? Color(.systemGray4) : Color(.systemRed))
  429. .tint(.white)
  430. .clipShape(RoundedRectangle(cornerRadius: 8))
  431. }
  432. }.padding(5)
  433. }
  434. }
  435. private var cancelAdjustmentButton: some View {
  436. switch state.selectedTab {
  437. case .overrides:
  438. Button(action: {
  439. Task {
  440. // Save cancelled Override in OverrideRunStored Entity
  441. // Cancel ALL active Override
  442. await state.disableAllActiveOverrides(createOverrideRunEntry: true)
  443. }
  444. }, label: {
  445. Text("Stop Override")
  446. })
  447. .frame(maxWidth: .infinity, alignment: .center)
  448. .disabled(!state.isEnabled)
  449. .listRowBackground(!state.isEnabled ? Color(.systemGray4) : Color(.systemRed))
  450. .tint(.white)
  451. case .tempTargets:
  452. Button(action: {
  453. Task {
  454. // Save cancelled Temp Targets in TempTargetRunStored Entity
  455. // Cancel ALL active Temp Targets
  456. await state.disableAllActiveTempTargets(createTempTargetRunEntry: true)
  457. // Update View
  458. state.updateLatestTempTargetConfiguration()
  459. }
  460. }, label: {
  461. Text("Stop Temp Target")
  462. })
  463. .frame(maxWidth: .infinity, alignment: .center)
  464. .disabled(!state.isTempTargetEnabled)
  465. .listRowBackground(!state.isTempTargetEnabled ? Color(.systemGray4) : Color(.systemRed))
  466. .tint(.white)
  467. }
  468. }
  469. private func tempTargetView(
  470. for tempTarget: TempTargetStored,
  471. showCheckmark: Bool = false,
  472. onTap: (() -> Void)? = nil
  473. ) -> some View {
  474. let target = tempTarget.target ?? 100
  475. let tempTargetValue = Decimal(target as! Double.RawValue)
  476. let isSelected = tempTarget.id?.uuidString == selectedPresetID
  477. let tempTargetHalfBasal = Decimal(
  478. tempTarget.halfBasalTarget as? Double
  479. .RawValue ?? Double(state.settingHalfBasalTarget)
  480. )
  481. let percentage = Int(
  482. state.computeAdjustedPercentage(usingHBT: tempTargetHalfBasal, usingTarget: tempTargetValue)
  483. )
  484. let remainingTime = tempTarget.date?.timeIntervalSinceNow ?? 0
  485. return ZStack(alignment: .trailing) {
  486. HStack {
  487. VStack(alignment: .leading) {
  488. HStack {
  489. Text(tempTarget.name ?? "")
  490. Spacer()
  491. if remainingTime > 0 {
  492. Text("Starts in \(formattedTimeRemaining(remainingTime))")
  493. .foregroundColor(colorScheme == .dark ? .orange : .accentColor)
  494. }
  495. }
  496. HStack(spacing: 2) {
  497. Text(formattedGlucose(glucose: target as Decimal))
  498. .foregroundColor(.secondary)
  499. .font(.caption)
  500. Text("for")
  501. .foregroundColor(.secondary)
  502. .font(.caption)
  503. Text("\(Formatter.integerFormatter.string(from: (tempTarget.duration ?? 0) as NSNumber)!)")
  504. .foregroundColor(.secondary)
  505. .font(.caption)
  506. Text("min")
  507. .foregroundColor(.secondary)
  508. .font(.caption)
  509. if state.isAdjustSensEnabled(usingTarget: tempTargetValue) {
  510. Text(", \(percentage)%")
  511. .foregroundColor(.secondary)
  512. .font(.caption)
  513. }
  514. Spacer()
  515. }
  516. .padding(.top, 2)
  517. }
  518. .contentShape(Rectangle())
  519. .onTapGesture {
  520. onTap?()
  521. }
  522. }
  523. if showCheckmark && isSelected {
  524. Image(systemName: "checkmark.circle.fill")
  525. .imageScale(.large)
  526. .fontWeight(.bold)
  527. .foregroundStyle(Color.green)
  528. } else if onTap != nil {
  529. Image(systemName: "line.3.horizontal")
  530. .imageScale(.medium)
  531. .foregroundStyle(.secondary)
  532. }
  533. }
  534. }
  535. private func formattedTimeRemaining(_ timeInterval: TimeInterval) -> String {
  536. let totalSeconds = Int(timeInterval)
  537. let hours = totalSeconds / 3600
  538. let minutes = (totalSeconds % 3600) / 60
  539. let seconds = totalSeconds % 60
  540. if hours > 0 {
  541. return "\(hours)h \(minutes)m \(seconds)s"
  542. } else if minutes > 0 {
  543. return "\(minutes)m \(seconds)s"
  544. } else {
  545. return "<1m"
  546. }
  547. }
  548. private var overrideLabelDivider: some View {
  549. Divider()
  550. .frame(width: 1, height: 20)
  551. }
  552. @ViewBuilder private func overridesView(for preset: OverrideStored) -> some View {
  553. let isSelected = preset.id == selectedPresetID
  554. let name = preset.name ?? ""
  555. let indefinite = preset.indefinite
  556. let duration = preset.duration?.decimalValue ?? Decimal(0)
  557. let percentage = preset.percentage
  558. let smbMinutes = preset.smbMinutes?.decimalValue ?? Decimal(0)
  559. let uamMinutes = preset.uamMinutes?.decimalValue ?? Decimal(0)
  560. let target: String = {
  561. guard let targetValue = preset.target, targetValue != 0 else { return "" }
  562. return state.units == .mgdL ? targetValue.description : targetValue.decimalValue.formattedAsMmolL
  563. }()
  564. let targetString = target.isEmpty ? "" : "\(target) \(state.units.rawValue)"
  565. let durationString = indefinite ? "" : "\(state.formatHrMin(Int(duration)))"
  566. let scheduledSMBString: String = {
  567. guard preset.smbIsScheduledOff, preset.start != preset.end else { return "" }
  568. return " \(formatTimeRange(start: preset.start?.stringValue, end: preset.end?.stringValue))"
  569. }()
  570. let smbString: String = {
  571. guard preset.smbIsOff || preset.smbIsScheduledOff else { return "" }
  572. return "SMBs Off\(scheduledSMBString)"
  573. }()
  574. let maxSmbMinsString: String = {
  575. guard smbMinutes != 0, preset.advancedSettings, !preset.smbIsOff,
  576. smbMinutes != state.defaultSmbMinutes else { return "" }
  577. return "\(smbMinutes.formatted()) min SMB"
  578. }()
  579. let maxUamMinsString: String = {
  580. guard uamMinutes != 0, preset.advancedSettings, !preset.smbIsOff,
  581. uamMinutes != state.defaultUamMinutes else { return "" }
  582. return "\(uamMinutes.formatted()) min UAM"
  583. }()
  584. let isfAndCrString: String = {
  585. switch (preset.isfAndCr, preset.isf, preset.cr) {
  586. case (_, true, true),
  587. (true, _, _):
  588. return " ISF/CR"
  589. case (false, true, false):
  590. return " ISF"
  591. case (false, false, true):
  592. return " CR"
  593. default:
  594. return ""
  595. }
  596. }()
  597. let percentageString = percentage != 100 ? "\(Int(percentage))%\(isfAndCrString)" : ""
  598. // Combine all labels into a single array, filtering out empty strings
  599. let labels: [String] = [
  600. durationString,
  601. percentageString,
  602. targetString,
  603. smbString,
  604. maxSmbMinsString,
  605. maxUamMinsString
  606. ].filter { !$0.isEmpty }
  607. if !name.isEmpty {
  608. ZStack(alignment: .trailing) {
  609. HStack {
  610. VStack {
  611. HStack {
  612. Text(name)
  613. Spacer()
  614. }
  615. HStack(spacing: 5) {
  616. ForEach(labels, id: \.self) { label in
  617. Text(label)
  618. if label != labels.last { // Add divider between labels
  619. overrideLabelDivider
  620. }
  621. }
  622. Spacer()
  623. }
  624. .padding(.top, 2)
  625. .foregroundColor(.secondary)
  626. .font(.caption)
  627. }
  628. .contentShape(Rectangle())
  629. .onTapGesture {
  630. Task {
  631. let objectID = preset.objectID
  632. await state.enactOverridePreset(withID: objectID)
  633. state.hideModal()
  634. showCheckmark.toggle()
  635. selectedPresetID = preset.id
  636. // Deactivate checkmark after 3 seconds
  637. DispatchQueue.main.asyncAfter(deadline: .now() + 3) {
  638. showCheckmark = false
  639. }
  640. }
  641. }
  642. }
  643. // show checkmark to indicate if the preset was actually pressed
  644. if showCheckmark && isSelected {
  645. Image(systemName: "checkmark.circle.fill")
  646. .imageScale(.large)
  647. .fontWeight(.bold)
  648. .foregroundStyle(Color.green)
  649. } else {
  650. Image(systemName: "line.3.horizontal")
  651. .imageScale(.medium)
  652. .foregroundStyle(.secondary)
  653. }
  654. }
  655. }
  656. }
  657. }
  658. }