BolusView.swift 14 KB

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