TherapySettingEditorView.swift 7.6 KB

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