TherapySettingEditorView.swift 9.3 KB

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