LoopAPNSBolusView.swift 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367
  1. // LoopFollow
  2. // LoopAPNSBolusView.swift
  3. // Created by Daniel Mini Johansson.
  4. import HealthKit
  5. import LocalAuthentication
  6. import SwiftUI
  7. struct LoopAPNSBolusView: View {
  8. @Environment(\.presentationMode) var presentationMode
  9. @State private var insulinAmount = HKQuantity(unit: .internationalUnit(), doubleValue: 0.0)
  10. @State private var isLoading = false
  11. @State private var showAlert = false
  12. @State private var alertMessage = ""
  13. @State private var alertType: AlertType = .success
  14. @FocusState private var insulinFieldIsFocused: Bool
  15. // Add state for recommended bolus and warning
  16. @State private var recommendedBolus: Double? = nil
  17. @State private var lastLoopTime: TimeInterval? = nil
  18. @State private var otpTimeRemaining: Int? = nil
  19. @State private var showOldCalculationWarning = false
  20. private let otpPeriod: TimeInterval = 30
  21. private var otpTimer = Timer.publish(every: 1, on: .main, in: .common).autoconnect()
  22. enum AlertType {
  23. case success
  24. case error
  25. case confirmation
  26. case oldCalculationWarning
  27. }
  28. var body: some View {
  29. NavigationView {
  30. VStack {
  31. Form {
  32. // Recommended bolus section
  33. if let recommendedBolus = recommendedBolus, recommendedBolus > 0, let lastLoopTime = lastLoopTime {
  34. let timeSinceCalculation = Date().timeIntervalSince1970 - lastLoopTime
  35. let minutesSinceCalculation = Int(timeSinceCalculation / 60)
  36. // Only show if calculation is less than 12 minutes old
  37. if minutesSinceCalculation < 12 {
  38. Section(header: Text("Recommended Bolus")) {
  39. Button(action: {
  40. handleRecommendedBolusTap()
  41. }) {
  42. HStack {
  43. VStack(alignment: .leading, spacing: 4) {
  44. Text("\(String(format: "%.2f", recommendedBolus))U")
  45. .font(.headline)
  46. .foregroundColor(.primary)
  47. Text("Calculated \(minutesSinceCalculation) minute\(minutesSinceCalculation == 1 ? "" : "s") ago")
  48. .font(.caption)
  49. .foregroundColor(.secondary)
  50. }
  51. Spacer()
  52. Image(systemName: "arrow.up.circle.fill")
  53. .foregroundColor(.blue)
  54. .font(.title2)
  55. }
  56. .padding(.vertical, 8)
  57. }
  58. .buttonStyle(PlainButtonStyle())
  59. }
  60. }
  61. }
  62. Section {
  63. HKQuantityInputView(
  64. label: "Insulin Amount",
  65. quantity: $insulinAmount,
  66. unit: .internationalUnit(),
  67. maxLength: 5,
  68. minValue: HKQuantity(unit: .internationalUnit(), doubleValue: 0.025),
  69. maxValue: Storage.shared.maxBolus.value,
  70. isFocused: $insulinFieldIsFocused,
  71. onValidationError: { message in
  72. alertMessage = message
  73. alertType = .error
  74. showAlert = true
  75. }
  76. )
  77. }
  78. // Warning section for recommended bolus age
  79. if let recommendedBolus = recommendedBolus, let lastLoopTime = lastLoopTime {
  80. let timeSinceCalculation = Date().timeIntervalSince1970 - lastLoopTime
  81. let minutesSinceCalculation = Int(timeSinceCalculation / 60)
  82. // Only show warning if calculation is less than 12 minutes old
  83. if minutesSinceCalculation < 12 {
  84. Section {
  85. let warningColor: Color = minutesSinceCalculation >= 5 ? .red : .yellow
  86. VStack(alignment: .leading, spacing: 8) {
  87. Text("WARNING: New treatments may have occurred since the last recommended bolus was calculated \(presentableMinutesFormat(timeInterval: timeSinceCalculation)) ago.")
  88. .font(.callout)
  89. .foregroundColor(warningColor)
  90. .multilineTextAlignment(.leading)
  91. }
  92. }
  93. }
  94. }
  95. Section {
  96. Button(action: sendInsulin) {
  97. if isLoading {
  98. HStack {
  99. ProgressView()
  100. .scaleEffect(0.8)
  101. Text("Sending...")
  102. }
  103. } else {
  104. Text("Send Insulin")
  105. }
  106. }
  107. .disabled(insulinAmount.doubleValue(for: .internationalUnit()) <= 0 || isLoading)
  108. .frame(maxWidth: .infinity)
  109. }
  110. Section(header: Text("Security")) {
  111. VStack(alignment: .leading) {
  112. Text("Current OTP Code")
  113. .font(.headline)
  114. if let otpCode = TOTPGenerator.extractOTPFromURL(Storage.shared.loopAPNSQrCodeURL.value) {
  115. HStack {
  116. Text(otpCode)
  117. .font(.system(.body, design: .monospaced))
  118. .foregroundColor(.green)
  119. .padding(.vertical, 4)
  120. .padding(.horizontal, 8)
  121. .background(Color.green.opacity(0.1))
  122. .cornerRadius(4)
  123. Text("(" + (otpTimeRemaining.map { "\($0)s left" } ?? "-") + ")")
  124. .font(.caption)
  125. .foregroundColor(.secondary)
  126. }
  127. } else {
  128. Text("Invalid QR code URL")
  129. .foregroundColor(.red)
  130. }
  131. }
  132. }
  133. }
  134. .navigationTitle("Insulin")
  135. .navigationBarTitleDisplayMode(.inline)
  136. }
  137. .onAppear {
  138. // Validate APNS setup
  139. let apnsService = LoopAPNSService()
  140. if !apnsService.validateSetup() {
  141. alertMessage = "Loop APNS setup is incomplete. Please configure all required fields in settings."
  142. alertType = .error
  143. showAlert = true
  144. }
  145. loadRecommendedBolus()
  146. // Reset timer state so it shows '-' until first tick
  147. otpTimeRemaining = nil
  148. }
  149. .onReceive(otpTimer) { _ in
  150. let now = Date().timeIntervalSince1970
  151. otpTimeRemaining = Int(otpPeriod - (now.truncatingRemainder(dividingBy: otpPeriod)))
  152. // Check if recommended bolus calculation is older than 5 minutes (but less than 12 minutes)
  153. if let lastLoopTime = lastLoopTime {
  154. let timeSinceCalculation = now - lastLoopTime
  155. let minutesSinceCalculation = Int(timeSinceCalculation / 60)
  156. // Only show warning if calculation is between 5-12 minutes old
  157. if minutesSinceCalculation > 5 && minutesSinceCalculation < 12 && !showOldCalculationWarning {
  158. showOldCalculationWarning = true
  159. alertMessage = "This recommended bolus was calculated \(minutesSinceCalculation) minutes ago. New treatments may have occurred since then. Proceed with caution."
  160. alertType = .oldCalculationWarning
  161. showAlert = true
  162. }
  163. }
  164. }
  165. .alert(isPresented: $showAlert) {
  166. switch alertType {
  167. case .success:
  168. return Alert(
  169. title: Text("Success"),
  170. message: Text(alertMessage),
  171. dismissButton: .default(Text("OK")) {
  172. presentationMode.wrappedValue.dismiss()
  173. }
  174. )
  175. case .error:
  176. return Alert(
  177. title: Text("Error"),
  178. message: Text(alertMessage),
  179. dismissButton: .default(Text("OK"))
  180. )
  181. case .confirmation:
  182. return Alert(
  183. title: Text("Confirm Insulin"),
  184. message: Text("Send \(String(format: "%.2f", insulinAmount.doubleValue(for: .internationalUnit()))) units of insulin?"),
  185. primaryButton: .default(Text("Send")) {
  186. authenticateAndSendInsulin()
  187. },
  188. secondaryButton: .cancel()
  189. )
  190. case .oldCalculationWarning:
  191. return Alert(
  192. title: Text("Old Calculation Warning"),
  193. message: Text(alertMessage),
  194. primaryButton: .default(Text("Use Anyway")) {
  195. applyRecommendedBolus()
  196. },
  197. secondaryButton: .cancel()
  198. )
  199. }
  200. }
  201. }
  202. }
  203. private func loadRecommendedBolus() {
  204. // Load recommended bolus from Observable
  205. recommendedBolus = Observable.shared.deviceRecBolus.value
  206. lastLoopTime = Observable.shared.alertLastLoopTime.value
  207. // Reset warning state when new data is loaded
  208. showOldCalculationWarning = false
  209. }
  210. private func handleRecommendedBolusTap() {
  211. guard let recommendedBolus = recommendedBolus, recommendedBolus > 0 else { return }
  212. // Apply the recommended bolus directly (warning is handled by timer)
  213. applyRecommendedBolus()
  214. }
  215. private func applyRecommendedBolus() {
  216. guard let recommendedBolus = recommendedBolus, recommendedBolus > 0 else { return }
  217. insulinAmount = HKQuantity(unit: .internationalUnit(), doubleValue: recommendedBolus)
  218. }
  219. private func presentableMinutesFormat(timeInterval: TimeInterval) -> String {
  220. let minutes = Int(timeInterval / 60)
  221. var result = "\(minutes) minute"
  222. if minutes == 0 || minutes > 1 {
  223. result += "s"
  224. }
  225. return result
  226. }
  227. private func sendInsulin() {
  228. guard insulinAmount.doubleValue(for: .internationalUnit()) > 0 else {
  229. alertMessage = "Please enter a valid insulin amount"
  230. alertType = .error
  231. showAlert = true
  232. return
  233. }
  234. // Check guardrails
  235. let maxBolus = Storage.shared.maxBolus.value.doubleValue(for: .internationalUnit())
  236. let insulinValue = insulinAmount.doubleValue(for: .internationalUnit())
  237. if insulinValue > maxBolus {
  238. alertMessage = "Insulin amount (\(String(format: "%.2f", insulinValue))U) exceeds the maximum allowed (\(String(format: "%.2f", maxBolus))U). Please reduce the amount."
  239. alertType = .error
  240. showAlert = true
  241. return
  242. }
  243. alertType = .confirmation
  244. showAlert = true
  245. }
  246. private func authenticateAndSendInsulin() {
  247. let context = LAContext()
  248. var error: NSError?
  249. let reason = "Confirm your identity to send insulin."
  250. if context.canEvaluatePolicy(.deviceOwnerAuthenticationWithBiometrics, error: &error) {
  251. context.evaluatePolicy(.deviceOwnerAuthenticationWithBiometrics, localizedReason: reason) { success, _ in
  252. DispatchQueue.main.async {
  253. if success {
  254. sendInsulinConfirmed()
  255. } else {
  256. alertMessage = "Authentication failed"
  257. alertType = .error
  258. showAlert = true
  259. }
  260. }
  261. }
  262. } else if context.canEvaluatePolicy(.deviceOwnerAuthentication, error: &error) {
  263. context.evaluatePolicy(.deviceOwnerAuthentication, localizedReason: reason) { success, _ in
  264. DispatchQueue.main.async {
  265. if success {
  266. sendInsulinConfirmed()
  267. } else {
  268. alertMessage = "Authentication failed"
  269. alertType = .error
  270. showAlert = true
  271. }
  272. }
  273. }
  274. } else {
  275. alertMessage = "Biometric authentication not available"
  276. alertType = .error
  277. showAlert = true
  278. }
  279. }
  280. private func sendInsulinConfirmed() {
  281. isLoading = true
  282. // Extract OTP from QR code URL
  283. guard let otpCode = TOTPGenerator.extractOTPFromURL(Storage.shared.loopAPNSQrCodeURL.value) else {
  284. alertMessage = "Invalid QR code URL. Please re-scan the QR code in settings."
  285. alertType = .error
  286. isLoading = false
  287. showAlert = true
  288. return
  289. }
  290. let payload = LoopAPNSPayload(
  291. type: .bolus,
  292. bolusAmount: insulinAmount.doubleValue(for: .internationalUnit()),
  293. otp: otpCode
  294. )
  295. Task {
  296. do {
  297. let apnsService = LoopAPNSService()
  298. let success = try await apnsService.sendBolusViaAPNS(payload: payload)
  299. DispatchQueue.main.async {
  300. isLoading = false
  301. if success {
  302. alertMessage = "Insulin sent successfully!"
  303. alertType = .success
  304. LogManager.shared.log(
  305. category: .apns,
  306. message: "Insulin sent - Amount: \(insulinAmount.doubleValue(for: .internationalUnit()))U"
  307. )
  308. } else {
  309. alertMessage = "Failed to send insulin. Check your Loop APNS configuration."
  310. alertType = .error
  311. LogManager.shared.log(
  312. category: .apns,
  313. message: "Failed to send insulin"
  314. )
  315. }
  316. showAlert = true
  317. }
  318. } catch {
  319. DispatchQueue.main.async {
  320. isLoading = false
  321. alertMessage = "Error sending insulin: \(error.localizedDescription)"
  322. alertType = .error
  323. LogManager.shared.log(
  324. category: .apns,
  325. message: "APNS insulin error: \(error.localizedDescription)"
  326. )
  327. showAlert = true
  328. }
  329. }
  330. }
  331. }
  332. }