BolusView.swift 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309
  1. // LoopFollow
  2. // BolusView.swift
  3. import HealthKit
  4. import LocalAuthentication
  5. import SwiftUI
  6. struct BolusView: View {
  7. @Environment(\.presentationMode) private var presentationMode
  8. @State private var bolusAmount = HKQuantity(unit: .internationalUnit(), doubleValue: 0.0)
  9. @ObservedObject private var maxBolus = Storage.shared.maxBolus
  10. @ObservedObject private var bolusIncrement = Storage.shared.bolusIncrement
  11. @ObservedObject private var deviceRecBolus = Observable.shared.deviceRecBolus
  12. @ObservedObject private var enactedOrSuggested = Observable.shared.enactedOrSuggested
  13. @FocusState private var bolusFieldIsFocused: Bool
  14. @State private var showAlert = false
  15. @State private var alertType: AlertType? = nil
  16. @State private var alertMessage: String? = nil
  17. @State private var isLoading = false
  18. @State private var statusMessage: String? = nil
  19. private let pushNotificationManager = PushNotificationManager()
  20. enum AlertType {
  21. case confirmBolus
  22. case statusSuccess
  23. case statusFailure
  24. case validation
  25. case oldCalculationWarning
  26. }
  27. // MARK: - Step/precision helpers driven by stored increment
  28. private var stepU: Double {
  29. max(0.001, bolusIncrement.value.doubleValue(for: .internationalUnit()))
  30. }
  31. private var stepFractionDigits: Int {
  32. let inc = stepU
  33. if inc >= 1 { return 0 }
  34. var v = inc
  35. var digits = 0
  36. while digits < 6 && abs(round(v) - v) > 1e-10 {
  37. v *= 10; digits += 1
  38. }
  39. return min(max(digits, 0), 5)
  40. }
  41. private func roundedToStep(_ value: Double) -> Double {
  42. guard stepU > 0 else { return value }
  43. let epsilon = 1e-10
  44. let stepped = ((value / stepU) + epsilon).rounded(.down) * stepU
  45. let p = pow(10.0, Double(stepFractionDigits))
  46. return (stepped * p).rounded() / p
  47. }
  48. // MARK: - View
  49. var body: some View {
  50. NavigationView {
  51. TimelineView(.periodic(from: .now, by: 1)) { context in
  52. Form {
  53. recommendedBlocks(now: context.date)
  54. Section {
  55. HKQuantityInputView(
  56. label: "Bolus Amount",
  57. quantity: $bolusAmount,
  58. unit: .internationalUnit(),
  59. maxLength: 5,
  60. minValue: HKQuantity(unit: .internationalUnit(), doubleValue: 0),
  61. maxValue: maxBolus.value,
  62. isFocused: $bolusFieldIsFocused,
  63. onValidationError: { message in
  64. handleValidationError(message)
  65. }
  66. )
  67. }
  68. }
  69. .safeAreaInset(edge: .bottom) {
  70. Button {
  71. bolusFieldIsFocused = false
  72. DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) {
  73. let rawValue = self.bolusAmount.doubleValue(for: .internationalUnit())
  74. let steppedAmount = roundedToStep(rawValue)
  75. if steppedAmount > 0 {
  76. bolusAmount = HKQuantity(unit: .internationalUnit(), doubleValue: steppedAmount)
  77. alertType = .confirmBolus
  78. showAlert = true
  79. }
  80. }
  81. } label: {
  82. if isLoading {
  83. HStack {
  84. ProgressView()
  85. .scaleEffect(0.8)
  86. Text("Sending Bolus...")
  87. }
  88. .frame(maxWidth: .infinity)
  89. } else {
  90. Text("Send Bolus")
  91. .frame(maxWidth: .infinity)
  92. }
  93. }
  94. .buttonStyle(.borderedProminent)
  95. .controlSize(.large)
  96. .disabled(isLoading)
  97. .padding(.horizontal)
  98. .padding(.vertical, 8)
  99. .background(.bar)
  100. }
  101. .navigationTitle("Bolus")
  102. .navigationBarTitleDisplayMode(.inline)
  103. }
  104. .alert(isPresented: $showAlert) {
  105. switch alertType {
  106. case .confirmBolus:
  107. return Alert(
  108. title: Text("Confirm Bolus"),
  109. message: Text("Are you sure you want to send \(InsulinFormatter.shared.string(bolusAmount)) U?"),
  110. primaryButton: .default(Text("Confirm"), action: {
  111. AuthService.authenticate(reason: "Confirm your identity to send bolus.") { result in
  112. DispatchQueue.main.async {
  113. switch result {
  114. case .success:
  115. self.sendBolus()
  116. case let .unavailable(message):
  117. self.alertMessage = message
  118. self.alertType = .validation
  119. self.showAlert = true
  120. case .failed:
  121. self.alertMessage = "Authentication failed"
  122. self.alertType = .validation
  123. self.showAlert = true
  124. case .canceled:
  125. // User canceled, no alert
  126. break
  127. }
  128. }
  129. }
  130. }),
  131. secondaryButton: .cancel()
  132. )
  133. case .statusSuccess:
  134. return Alert(
  135. title: Text("Status"),
  136. message: Text(statusMessage ?? ""),
  137. dismissButton: .default(Text("OK"), action: {
  138. presentationMode.wrappedValue.dismiss()
  139. })
  140. )
  141. case .statusFailure:
  142. return Alert(
  143. title: Text("Status"),
  144. message: Text(statusMessage ?? ""),
  145. dismissButton: .default(Text("OK"))
  146. )
  147. case .validation:
  148. return Alert(
  149. title: Text("Validation Error"),
  150. message: Text(alertMessage ?? "Invalid input."),
  151. dismissButton: .default(Text("OK"))
  152. )
  153. case .oldCalculationWarning:
  154. return Alert(
  155. title: Text("Old Calculation Warning"),
  156. message: Text(alertMessage ?? ""),
  157. primaryButton: .default(Text("Use Anyway")) {
  158. if let rec = deviceRecBolus.value {
  159. applyRecommendedBolus(rec)
  160. }
  161. },
  162. secondaryButton: .cancel()
  163. )
  164. case .none:
  165. return Alert(title: Text("Unknown Alert"))
  166. }
  167. }
  168. }
  169. }
  170. // MARK: - Recommended bolus UI
  171. @ViewBuilder
  172. private func recommendedBlocks(now: Date) -> some View {
  173. if let rec = deviceRecBolus.value,
  174. let t = enactedOrSuggested.value
  175. {
  176. let ageSec = max(0, now.timeIntervalSince1970 - t)
  177. if ageSec < 12 * 60 {
  178. let maxU = maxBolus.value.doubleValue(for: .internationalUnit())
  179. let clamped = min(rec, maxU)
  180. let steppedRec = roundedToStep(clamped)
  181. if steppedRec > 0 {
  182. let mins = Int(ageSec / 60)
  183. let isStale5 = ageSec >= 5 * 60
  184. Section(header: Text("Recommended Bolus")) {
  185. Button {
  186. handleRecommendedBolusTap(rec: steppedRec, ageSec: ageSec)
  187. } label: {
  188. HStack {
  189. VStack(alignment: .leading, spacing: 4) {
  190. Text("\(InsulinFormatter.shared.string(steppedRec))U")
  191. Text("Calculated \(mins) minute\(mins == 1 ? "" : "s") ago")
  192. .font(.caption)
  193. .foregroundColor(.secondary)
  194. }
  195. Spacer()
  196. Image(systemName: "arrow.up.circle.fill")
  197. .font(.title2)
  198. }
  199. .padding(.vertical, 8)
  200. }
  201. .buttonStyle(PlainButtonStyle())
  202. }
  203. Section {
  204. let color: Color = isStale5 ? .red : .yellow
  205. Text("WARNING: New treatments may have occurred since the last recommended bolus was calculated \(presentableMinutesFormat(timeInterval: ageSec)) ago.")
  206. .font(.callout)
  207. .foregroundColor(color)
  208. .multilineTextAlignment(.leading)
  209. }
  210. } else {
  211. EmptyView()
  212. }
  213. } else {
  214. EmptyView()
  215. }
  216. } else {
  217. EmptyView()
  218. }
  219. }
  220. private func handleRecommendedBolusTap(rec: Double, ageSec: TimeInterval) {
  221. let isStale5 = ageSec >= 5 * 60
  222. let isStale12 = ageSec >= 12 * 60
  223. if isStale12 { return }
  224. if isStale5 {
  225. let mins = Int(ageSec / 60)
  226. alertMessage = "This recommended bolus was calculated \(mins) minutes ago. New treatments may have occurred since then. Proceed with caution."
  227. alertType = .oldCalculationWarning
  228. showAlert = true
  229. } else {
  230. applyRecommendedBolus(rec)
  231. }
  232. }
  233. private func applyRecommendedBolus(_ rec: Double) {
  234. let maxU = maxBolus.value.doubleValue(for: .internationalUnit())
  235. let clamped = min(rec, maxU)
  236. let stepped = roundedToStep(clamped)
  237. bolusAmount = HKQuantity(unit: .internationalUnit(), doubleValue: stepped)
  238. }
  239. private func presentableMinutesFormat(timeInterval: TimeInterval) -> String {
  240. let minutes = max(0, Int(timeInterval / 60))
  241. var s = "\(minutes) minute"
  242. if minutes == 0 || minutes > 1 { s += "s" }
  243. return s
  244. }
  245. // MARK: - Send
  246. private func sendBolus() {
  247. isLoading = true
  248. pushNotificationManager.sendBolusPushNotification(bolusAmount: bolusAmount) { success, errorMessage in
  249. DispatchQueue.main.async {
  250. isLoading = false
  251. if success {
  252. statusMessage = "Bolus command sent successfully."
  253. LogManager.shared.log(
  254. category: .apns,
  255. message: "sendBolusPushNotification succeeded - Bolus: \(InsulinFormatter.shared.string(bolusAmount)) U"
  256. )
  257. bolusAmount = HKQuantity(unit: .internationalUnit(), doubleValue: 0.0)
  258. alertType = .statusSuccess
  259. } else {
  260. statusMessage = errorMessage ?? "Failed to send bolus command."
  261. LogManager.shared.log(
  262. category: .apns,
  263. message: "sendBolusPushNotification failed with error: \(errorMessage ?? "unknown error")"
  264. )
  265. alertType = .statusFailure
  266. }
  267. showAlert = true
  268. }
  269. }
  270. }
  271. private func handleValidationError(_ message: String) {
  272. alertMessage = message
  273. alertType = .validation
  274. showAlert = true
  275. }
  276. }