TherapySettingEditorView.swift 14 KB

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