TherapySettingEditorView.swift 8.6 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244
  1. import SwiftUI
  2. struct TherapySettingEditorView: View {
  3. @Binding var items: [TherapySettingItem]
  4. var unit: TherapySettingUnit
  5. var timeOptions: [TimeInterval]
  6. var valueOptions: [Decimal]
  7. var validateOnDelete: (() -> Void)?
  8. @State private var selectedItemID: UUID?
  9. var body: some View {
  10. List {
  11. HStack {
  12. Text("Entries").bold()
  13. Spacer()
  14. Button {
  15. let lastTime = items.last?.time ?? 0
  16. let newTime = min(lastTime + 1800, 23 * 3600 + 1800)
  17. let newValue = items.last?.value ?? 1.0
  18. items.append(TherapySettingItem(time: newTime, value: newValue))
  19. selectedItemID = nil
  20. } label: {
  21. HStack {
  22. Image(systemName: "plus.circle.fill")
  23. Text("Add")
  24. }.foregroundColor(.accentColor)
  25. }
  26. .disabled(items.count >= 48)
  27. }
  28. .listRowBackground(Color.chart.opacity(0.65))
  29. .padding(.vertical, 5)
  30. ForEach($items) { $item in
  31. VStack(spacing: 0) {
  32. Button {
  33. selectedItemID = selectedItemID == item.id ? nil : item.id
  34. Task { @MainActor in
  35. withAnimation {
  36. items = items.sorted { $0.time < $1.time }
  37. }
  38. }
  39. } label: {
  40. HStack {
  41. HStack {
  42. Text(displayText(for: unit, decimalValue: item.value))
  43. .foregroundStyle(selectedItemID == item.id ? Color.accentColor : Color.primary)
  44. Text(unit.displayName)
  45. .foregroundStyle(Color.secondary)
  46. }
  47. Spacer()
  48. HStack {
  49. Text("starts at").foregroundStyle(Color.secondary)
  50. let timeIndex = timeOptions.firstIndex { abs($0 - item.time) < 1 } ?? 0
  51. let time = timeOptions[timeIndex]
  52. let date = Date(timeIntervalSince1970: time)
  53. let timeString = timeFormatter.string(from: date)
  54. Text(timeString)
  55. .foregroundStyle(selectedItemID == item.id ? Color.accentColor : Color.primary)
  56. }
  57. }.contentShape(Rectangle())
  58. }
  59. .buttonStyle(.plain)
  60. if selectedItemID == item.id {
  61. timeValuePickerRow(
  62. item: $item,
  63. timeOptions: timeOptions,
  64. valueOptions: valueOptions,
  65. unit: unit
  66. )
  67. .transition(.slide)
  68. }
  69. }
  70. .swipeActions(edge: .trailing, allowsFullSwipe: true) {
  71. if let index = items.firstIndex(where: { $0.id == item.id }), items.count > 1 {
  72. Button(role: .destructive) {
  73. items.remove(at: index)
  74. selectedItemID = nil
  75. validateTherapySettingItems()
  76. } label: {
  77. Label("Delete", systemImage: "trash")
  78. }
  79. }
  80. }
  81. }
  82. .listRowBackground(Color.chart.opacity(0.65))
  83. Rectangle().fill(Color.chart.opacity(0.65)).frame(height: 10)
  84. .clipShape(
  85. .rect(
  86. topLeadingRadius: 0,
  87. bottomLeadingRadius: 10,
  88. bottomTrailingRadius: 10,
  89. topTrailingRadius: 0
  90. )
  91. )
  92. .listRowBackground(Color.clear)
  93. .listRowInsets(EdgeInsets(top: -22, leading: 0, bottom: 0, trailing: 0))
  94. .listRowSeparator(.hidden)
  95. }
  96. .listStyle(.plain)
  97. .scrollDisabled(true)
  98. .scrollContentBackground(.hidden)
  99. .frame(height: 55 + CGFloat(items.count) * 45 + (items.contains(where: { $0.id == selectedItemID }) ? 230 : 0))
  100. // 55 for header row, item counts x 45 for every entry row + 230 for a visible picker row
  101. }
  102. @ViewBuilder private func timeValuePickerRow(
  103. item: Binding<TherapySettingItem>,
  104. timeOptions: [TimeInterval],
  105. valueOptions: [Decimal],
  106. unit: TherapySettingUnit
  107. ) -> some View {
  108. VStack(spacing: 8) {
  109. HStack {
  110. Picker("Value", selection: Binding(
  111. get: { Double(item.wrappedValue.value) },
  112. set: { item.wrappedValue.value = Decimal($0) }
  113. )) {
  114. ForEach(valueOptions, id: \.self) { value in
  115. Text("\(displayText(for: unit, decimalValue: value)) \(unit.displayName)").tag(Double(value))
  116. }
  117. }
  118. .frame(maxWidth: .infinity)
  119. .clipped()
  120. Picker("Time", selection: Binding(
  121. get: { item.wrappedValue.time },
  122. set: { item.wrappedValue.time = $0 }
  123. )) {
  124. ForEach(timeOptions, id: \.self) { time in
  125. Text(timeFormatter.string(from: Date(timeIntervalSince1970: time)))
  126. .tag(time)
  127. }
  128. }
  129. .frame(maxWidth: .infinity)
  130. .clipped()
  131. }
  132. .pickerStyle(.wheel)
  133. }
  134. .padding(.vertical, 8)
  135. }
  136. private func validateTherapySettingItems() {
  137. // validates therapy items (i.e. parsed therapy settings into wrapper class)
  138. var newItems = Array(Set(items)).sorted { $0.time < $1.time }
  139. if let first = newItems.first {
  140. first.time = 0
  141. }
  142. if items != newItems {
  143. items = newItems
  144. }
  145. // validates underlying "raw" therapy setting (i.e. item of type basal, target, isf, carb ratio)
  146. validateOnDelete?()
  147. }
  148. private var timeFormatter: DateFormatter {
  149. let formatter = DateFormatter()
  150. formatter.timeZone = TimeZone(secondsFromGMT: 0)
  151. formatter.timeStyle = .short
  152. return formatter
  153. }
  154. private func displayText(for unit: TherapySettingUnit, decimalValue: Decimal) -> String {
  155. switch unit {
  156. case .mmolL,
  157. .mmolLPerUnit:
  158. return decimalValue.formattedAsMmolL
  159. case .gramPerUnit,
  160. .mgdL,
  161. .mgdLPerUnit,
  162. .unitPerHour:
  163. return decimalValue.description
  164. }
  165. }
  166. }
  167. class TherapySettingItem: Identifiable, Equatable, Hashable {
  168. var id = UUID()
  169. var time: TimeInterval = 0 // seconds since start of day
  170. var value: Decimal = 0
  171. init(time: TimeInterval, value: Decimal) {
  172. self.time = time
  173. self.value = value
  174. }
  175. static func == (lhs: TherapySettingItem, rhs: TherapySettingItem) -> Bool {
  176. lhs.time == rhs.time && lhs.value == rhs.value
  177. }
  178. func hash(into hasher: inout Hasher) {
  179. hasher.combine(time)
  180. hasher.combine(value)
  181. }
  182. }
  183. enum TherapySettingUnit: String, CaseIterable {
  184. case mmolLPerUnit
  185. case mgdLPerUnit
  186. case unitPerHour
  187. case gramPerUnit
  188. case mmolL
  189. case mgdL
  190. var id: String { rawValue }
  191. var displayName: String {
  192. switch self {
  193. case .mmolLPerUnit:
  194. return String(localized: "mmol/L/U")
  195. case .mgdLPerUnit:
  196. return String(localized: "mg/dL/U")
  197. case .unitPerHour:
  198. return String(localized: "U/hr")
  199. case .gramPerUnit:
  200. return String(localized: "g/U")
  201. case .mmolL:
  202. return "mmol/L"
  203. case .mgdL:
  204. return "mg/dL"
  205. }
  206. }
  207. }
  208. #Preview {
  209. @Previewable @State var previewItems = [
  210. TherapySettingItem(time: 0, value: 1.0),
  211. TherapySettingItem(time: 1800, value: 1.2)
  212. ]
  213. TherapySettingEditorView(
  214. items: $previewItems,
  215. unit: .unitPerHour,
  216. timeOptions: stride(from: 0.0, to: 1.days.timeInterval, by: 30.minutes.timeInterval).map { $0 },
  217. valueOptions: stride(from: 0.0, through: 10.0, by: 0.05).map { Decimal(round(100 * $0) / 100) }
  218. )
  219. }