TherapySettingEditorView.swift 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303
  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. // Prepare and add new entry
  16. let lastTime = items.last?.time ?? 0
  17. let newTime = min(lastTime + 1800, 23 * 3600 + 1800)
  18. let newValue = items.last?.value ?? 1.0
  19. items.append(TherapySettingItem(time: newTime, value: newValue))
  20. // Reset selected item to close picker
  21. selectedItemID = nil
  22. // Sort items, in case user has changed time of one item, then taps 'Add'
  23. sortTherapyItems()
  24. } label: {
  25. HStack {
  26. Image(systemName: "plus.circle.fill")
  27. Text("Add")
  28. }.foregroundColor(.accentColor)
  29. }
  30. .disabled(items.count >= 48)
  31. }
  32. .listRowBackground(Color.chart.opacity(0.65))
  33. .padding(.vertical, 5)
  34. ForEach($items) { $item in
  35. // Determine if this is first item in list (which is locked to 00:00)
  36. var isFirstItem: Bool {
  37. items.first == $item.wrappedValue
  38. }
  39. VStack(spacing: 0) {
  40. Button {
  41. selectedItemID = selectedItemID == item.id ? nil : item.id
  42. sortTherapyItems()
  43. } label: {
  44. HStack {
  45. HStack {
  46. Text(displayText(for: unit, decimalValue: item.value))
  47. .foregroundStyle(
  48. selectedItemID == item.id ? Color.accentColor : Color
  49. .primary
  50. )
  51. Text(unit.displayName)
  52. .foregroundStyle(Color.secondary)
  53. }
  54. Spacer()
  55. HStack {
  56. Text("starts at").foregroundStyle(Color.secondary)
  57. let timeIndex = timeOptions.firstIndex { abs($0 - item.time) < 1 } ?? 0
  58. let time = timeOptions[timeIndex]
  59. let date = Date(timeIntervalSince1970: time)
  60. let timeString = timeFormatter.string(from: date)
  61. Text(timeString).foregroundStyle(selectedItemID == item.id ? Color.accentColor : Color.primary)
  62. }
  63. }
  64. .contentShape(Rectangle())
  65. }
  66. .buttonStyle(.plain)
  67. if selectedItemID == item.id {
  68. timeValuePickerRow(
  69. item: $item,
  70. timeOptions: timeOptions,
  71. valueOptions: valueOptions,
  72. unit: unit
  73. )
  74. .transition(.slide)
  75. }
  76. }
  77. .swipeActions(edge: .trailing, allowsFullSwipe: true) {
  78. if let index = items.firstIndex(where: { $0.id == item.id }), items.count > 1 {
  79. Button(role: .destructive) {
  80. items.remove(at: index)
  81. selectedItemID = nil
  82. validateTherapySettingItems()
  83. } label: {
  84. Label("Delete", systemImage: "trash")
  85. }
  86. }
  87. }
  88. }
  89. .listRowBackground(Color.chart.opacity(0.65))
  90. Rectangle().fill(Color.chart.opacity(0.65)).frame(height: 10)
  91. .clipShape(
  92. .rect(
  93. topLeadingRadius: 0,
  94. bottomLeadingRadius: 10,
  95. bottomTrailingRadius: 10,
  96. topTrailingRadius: 0
  97. )
  98. )
  99. .listRowBackground(Color.clear)
  100. .listRowInsets(EdgeInsets(top: -22, leading: 0, bottom: 0, trailing: 0))
  101. .listRowSeparator(.hidden)
  102. }
  103. .listStyle(.plain)
  104. .scrollDisabled(true)
  105. .scrollContentBackground(.hidden)
  106. // 55 for header row, item counts x 45 for every entry row + 230 for a visible picker row
  107. .frame(height: 55 + CGFloat(items.count) * 45 + (items.contains(where: { $0.id == selectedItemID }) ? 230 : 0))
  108. .onAppear {
  109. // ensure picker is closed when view appears
  110. selectedItemID = nil
  111. validateTherapySettingItems()
  112. }
  113. .onChange(of: items, { _, _ in
  114. validateTherapySettingItems()
  115. })
  116. }
  117. @ViewBuilder private func timeValuePickerRow(
  118. item: Binding<TherapySettingItem>,
  119. timeOptions: [TimeInterval],
  120. valueOptions: [Decimal],
  121. unit: TherapySettingUnit
  122. ) -> some View {
  123. // Compute unavailable times (already taken by other entries)
  124. let takenTimes = Set(items.filter { $0.id != item.wrappedValue.id }.map(\.time))
  125. // Allow current selection even if it’s in the set of taken times.
  126. let availableTimes = timeOptions.filter { $0 == item.wrappedValue.time || !takenTimes.contains($0) }
  127. // Determine if this is first item in list (which is locked to 00:00)
  128. var isFirstItem: Bool {
  129. items.first == item.wrappedValue
  130. }
  131. VStack(spacing: 8) {
  132. HStack {
  133. Picker("Value", selection: Binding(
  134. get: { Double(item.wrappedValue.value) },
  135. set: {
  136. item.wrappedValue.value = Decimal($0)
  137. }
  138. )) {
  139. ForEach(valueOptions, id: \.self) { value in
  140. Text("\(displayText(for: unit, decimalValue: value)) \(unit.displayName)").tag(Double(value))
  141. }
  142. }
  143. .frame(maxWidth: .infinity)
  144. .clipped()
  145. Picker("Time", selection: Binding(
  146. get: { item.wrappedValue.time },
  147. set: { newTime in
  148. // Only update if new time is either not taken, or it is the current value
  149. if newTime == item.wrappedValue.time || !takenTimes.contains(newTime) {
  150. item.wrappedValue.time = newTime
  151. validateTherapySettingItems()
  152. }
  153. }
  154. )) {
  155. ForEach(availableTimes, id: \.self) { time in
  156. Text(timeFormatter.string(from: Date(timeIntervalSince1970: time)))
  157. .tag(time)
  158. .foregroundStyle(item.wrappedValue.time != 0 ? Color.primary : Color.secondary)
  159. }
  160. }
  161. // Lock time picker if first item and make it slightly opague
  162. .opacity(isFirstItem ? 0.5 : 1)
  163. .disabled(isFirstItem)
  164. .frame(maxWidth: .infinity)
  165. .clipped()
  166. }
  167. .pickerStyle(.wheel)
  168. }
  169. .padding(.vertical, 8)
  170. }
  171. private func sortTherapyItems() {
  172. Task { @MainActor in
  173. withAnimation {
  174. items = items.sorted { $0.time < $1.time }
  175. }
  176. }
  177. }
  178. private func validateTherapySettingItems() {
  179. // validates therapy items (i.e. parsed therapy settings into wrapper class)
  180. var newItems = Array(Set(items)).sorted { $0.time < $1.time }
  181. if !newItems.isEmpty {
  182. var first = newItems[0]
  183. if first.time != 0 {
  184. first.time = 0
  185. }
  186. newItems[0] = first
  187. }
  188. // force ALL items to have new UUIDs (to enforce binding update)
  189. items = newItems.map { TherapySettingItem(copying: $0, newID: true) }
  190. // validates underlying "raw" therapy setting (i.e. item of type basal, target, isf, carb ratio)
  191. validateOnDelete?()
  192. }
  193. private var timeFormatter: DateFormatter {
  194. let formatter = DateFormatter()
  195. formatter.timeZone = TimeZone(secondsFromGMT: 0)
  196. formatter.timeStyle = .short
  197. return formatter
  198. }
  199. private func displayText(for unit: TherapySettingUnit, decimalValue: Decimal) -> String {
  200. switch unit {
  201. case .mmolL,
  202. .mmolLPerUnit:
  203. return decimalValue.formattedAsMmolL
  204. case .gramPerUnit,
  205. .mgdL,
  206. .mgdLPerUnit,
  207. .unitPerHour:
  208. return decimalValue.description
  209. }
  210. }
  211. }
  212. struct TherapySettingItem: Identifiable, Equatable, Hashable {
  213. var id = UUID()
  214. var time: TimeInterval = 0 // seconds since start of day
  215. var value: Decimal = 0
  216. init(time: TimeInterval, value: Decimal) {
  217. self.time = time
  218. self.value = value
  219. }
  220. static func == (lhs: TherapySettingItem, rhs: TherapySettingItem) -> Bool {
  221. lhs.time == rhs.time && lhs.value == rhs.value
  222. }
  223. func hash(into hasher: inout Hasher) {
  224. hasher.combine(time)
  225. hasher.combine(value)
  226. }
  227. }
  228. /// Convenience extension to ease copying of existing `TherapySettingItem`s
  229. extension TherapySettingItem {
  230. init(copying item: TherapySettingItem, newID: Bool = false) {
  231. id = newID ? UUID() : item.id
  232. time = item.time
  233. value = item.value
  234. }
  235. }
  236. enum TherapySettingUnit: String, CaseIterable {
  237. case mmolLPerUnit
  238. case mgdLPerUnit
  239. case unitPerHour
  240. case gramPerUnit
  241. case mmolL
  242. case mgdL
  243. var id: String { rawValue }
  244. var displayName: String {
  245. switch self {
  246. case .mmolLPerUnit:
  247. return String(localized: "mmol/L/U")
  248. case .mgdLPerUnit:
  249. return String(localized: "mg/dL/U")
  250. case .unitPerHour:
  251. return String(localized: "U/hr")
  252. case .gramPerUnit:
  253. return String(localized: "g/U")
  254. case .mmolL:
  255. return "mmol/L"
  256. case .mgdL:
  257. return "mg/dL"
  258. }
  259. }
  260. }
  261. #Preview {
  262. @Previewable @State var previewItems = [
  263. TherapySettingItem(time: 0, value: 1.0),
  264. TherapySettingItem(time: 1800, value: 1.2)
  265. ]
  266. TherapySettingEditorView(
  267. items: $previewItems,
  268. unit: .unitPerHour,
  269. timeOptions: stride(from: 0.0, to: 1.days.timeInterval, by: 30.minutes.timeInterval).map { $0 },
  270. valueOptions: stride(from: 0.0, through: 10.0, by: 0.05).map { Decimal(round(100 * $0) / 100) }
  271. )
  272. }