BolusView.swift 11 KB

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