AdjustmentsRootView.swift 31 KB

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