BolusView.swift 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296
  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. LoadingButtonView(
  69. buttonText: "Send Bolus",
  70. progressText: "Sending Bolus...",
  71. isLoading: isLoading,
  72. action: {
  73. bolusFieldIsFocused = false
  74. DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) {
  75. let rawValue = self.bolusAmount.doubleValue(for: .internationalUnit())
  76. let steppedAmount = roundedToStep(rawValue)
  77. if steppedAmount > 0 {
  78. bolusAmount = HKQuantity(unit: .internationalUnit(), doubleValue: steppedAmount)
  79. alertType = .confirmBolus
  80. showAlert = true
  81. }
  82. }
  83. },
  84. isDisabled: isLoading
  85. )
  86. }
  87. .navigationTitle("Bolus")
  88. .navigationBarTitleDisplayMode(.inline)
  89. }
  90. .alert(isPresented: $showAlert) {
  91. switch alertType {
  92. case .confirmBolus:
  93. return Alert(
  94. title: Text("Confirm Bolus"),
  95. message: Text("Are you sure you want to send \(InsulinFormatter.shared.string(bolusAmount)) U?"),
  96. primaryButton: .default(Text("Confirm"), action: {
  97. AuthService.authenticate(reason: "Confirm your identity to send bolus.") { result in
  98. DispatchQueue.main.async {
  99. switch result {
  100. case .success:
  101. self.sendBolus()
  102. case let .unavailable(message):
  103. self.alertMessage = message
  104. self.alertType = .validation
  105. self.showAlert = true
  106. case .failed:
  107. self.alertMessage = "Authentication failed"
  108. self.alertType = .validation
  109. self.showAlert = true
  110. case .canceled:
  111. // User canceled, no alert
  112. break
  113. }
  114. }
  115. }
  116. }),
  117. secondaryButton: .cancel()
  118. )
  119. case .statusSuccess:
  120. return Alert(
  121. title: Text("Status"),
  122. message: Text(statusMessage ?? ""),
  123. dismissButton: .default(Text("OK"), action: {
  124. presentationMode.wrappedValue.dismiss()
  125. })
  126. )
  127. case .statusFailure:
  128. return Alert(
  129. title: Text("Status"),
  130. message: Text(statusMessage ?? ""),
  131. dismissButton: .default(Text("OK"))
  132. )
  133. case .validation:
  134. return Alert(
  135. title: Text("Validation Error"),
  136. message: Text(alertMessage ?? "Invalid input."),
  137. dismissButton: .default(Text("OK"))
  138. )
  139. case .oldCalculationWarning:
  140. return Alert(
  141. title: Text("Old Calculation Warning"),
  142. message: Text(alertMessage ?? ""),
  143. primaryButton: .default(Text("Use Anyway")) {
  144. if let rec = deviceRecBolus.value {
  145. applyRecommendedBolus(rec)
  146. }
  147. },
  148. secondaryButton: .cancel()
  149. )
  150. case .none:
  151. return Alert(title: Text("Unknown Alert"))
  152. }
  153. }
  154. }
  155. }
  156. // MARK: - Recommended bolus UI
  157. @ViewBuilder
  158. private func recommendedBlocks(now: Date) -> some View {
  159. if let rec = deviceRecBolus.value,
  160. let t = enactedOrSuggested.value
  161. {
  162. let ageSec = max(0, now.timeIntervalSince1970 - t)
  163. if ageSec < 12 * 60 {
  164. let maxU = maxBolus.value.doubleValue(for: .internationalUnit())
  165. let clamped = min(rec, maxU)
  166. let steppedRec = roundedToStep(clamped)
  167. if steppedRec > 0 {
  168. let mins = Int(ageSec / 60)
  169. let isStale5 = ageSec >= 5 * 60
  170. Section(header: Text("Recommended Bolus")) {
  171. Button {
  172. handleRecommendedBolusTap(rec: steppedRec, ageSec: ageSec)
  173. } label: {
  174. HStack {
  175. VStack(alignment: .leading, spacing: 4) {
  176. Text("\(InsulinFormatter.shared.string(steppedRec))U")
  177. Text("Calculated \(mins) minute\(mins == 1 ? "" : "s") ago")
  178. .font(.caption)
  179. .foregroundColor(.secondary)
  180. }
  181. Spacer()
  182. Image(systemName: "arrow.up.circle.fill")
  183. .font(.title2)
  184. }
  185. .padding(.vertical, 8)
  186. }
  187. .buttonStyle(PlainButtonStyle())
  188. }
  189. Section {
  190. let color: Color = isStale5 ? .red : .yellow
  191. Text("WARNING: New treatments may have occurred since the last recommended bolus was calculated \(presentableMinutesFormat(timeInterval: ageSec)) ago.")
  192. .font(.callout)
  193. .foregroundColor(color)
  194. .multilineTextAlignment(.leading)
  195. }
  196. } else {
  197. EmptyView()
  198. }
  199. } else {
  200. EmptyView()
  201. }
  202. } else {
  203. EmptyView()
  204. }
  205. }
  206. private func handleRecommendedBolusTap(rec: Double, ageSec: TimeInterval) {
  207. let isStale5 = ageSec >= 5 * 60
  208. let isStale12 = ageSec >= 12 * 60
  209. if isStale12 { return }
  210. if isStale5 {
  211. let mins = Int(ageSec / 60)
  212. alertMessage = "This recommended bolus was calculated \(mins) minutes ago. New treatments may have occurred since then. Proceed with caution."
  213. alertType = .oldCalculationWarning
  214. showAlert = true
  215. } else {
  216. applyRecommendedBolus(rec)
  217. }
  218. }
  219. private func applyRecommendedBolus(_ rec: Double) {
  220. let maxU = maxBolus.value.doubleValue(for: .internationalUnit())
  221. let clamped = min(rec, maxU)
  222. let stepped = roundedToStep(clamped)
  223. bolusAmount = HKQuantity(unit: .internationalUnit(), doubleValue: stepped)
  224. }
  225. private func presentableMinutesFormat(timeInterval: TimeInterval) -> String {
  226. let minutes = max(0, Int(timeInterval / 60))
  227. var s = "\(minutes) minute"
  228. if minutes == 0 || minutes > 1 { s += "s" }
  229. return s
  230. }
  231. // MARK: - Send
  232. private func sendBolus() {
  233. isLoading = true
  234. pushNotificationManager.sendBolusPushNotification(bolusAmount: bolusAmount) { success, errorMessage in
  235. DispatchQueue.main.async {
  236. isLoading = false
  237. if success {
  238. statusMessage = "Bolus command sent successfully."
  239. LogManager.shared.log(
  240. category: .apns,
  241. message: "sendBolusPushNotification succeeded - Bolus: \(InsulinFormatter.shared.string(bolusAmount)) U"
  242. )
  243. bolusAmount = HKQuantity(unit: .internationalUnit(), doubleValue: 0.0)
  244. alertType = .statusSuccess
  245. } else {
  246. statusMessage = errorMessage ?? "Failed to send bolus command."
  247. LogManager.shared.log(
  248. category: .apns,
  249. message: "sendBolusPushNotification failed with error: \(errorMessage ?? "unknown error")"
  250. )
  251. alertType = .statusFailure
  252. }
  253. showAlert = true
  254. }
  255. }
  256. }
  257. private func handleValidationError(_ message: String) {
  258. alertMessage = message
  259. alertType = .validation
  260. showAlert = true
  261. }
  262. }