LoopAPNSCarbsView.swift 19 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439
  1. // LoopFollow
  2. // LoopAPNSCarbsView.swift
  3. import HealthKit
  4. import SwiftUI
  5. struct LoopAPNSCarbsView: View {
  6. @Environment(\.presentationMode) var presentationMode
  7. @State private var carbsAmount = HKQuantity(unit: .gram(), doubleValue: 0.0)
  8. @State private var absorptionTimeString = "3.0"
  9. @State private var foodType = ""
  10. @State private var consumedDate = Date()
  11. @State private var showDatePickerSheet = false
  12. @State private var isLoading = false
  13. @State private var showAlert = false
  14. @State private var alertMessage = ""
  15. @State private var alertType: AlertType = .success
  16. @State private var otpTimeRemaining: Int? = nil
  17. private let otpPeriod: TimeInterval = 30
  18. private var otpTimer = Timer.publish(every: 1, on: .main, in: .common).autoconnect()
  19. @FocusState private var carbsFieldIsFocused: Bool
  20. @FocusState private var absorptionFieldIsFocused: Bool
  21. // Computed property to check if TOTP should be blocked
  22. private var isTOTPBlocked: Bool {
  23. TOTPService.shared.isTOTPBlocked(qrCodeURL: Storage.shared.loopAPNSQrCodeURL.value)
  24. }
  25. enum AlertType {
  26. case success
  27. case error
  28. case confirmation
  29. }
  30. var body: some View {
  31. NavigationView {
  32. VStack {
  33. Form {
  34. Section {
  35. HKQuantityInputView(
  36. label: "Carbs Amount",
  37. quantity: $carbsAmount,
  38. unit: .gram(),
  39. maxLength: 4,
  40. minValue: HKQuantity(unit: .gram(), doubleValue: 1.0),
  41. maxValue: Storage.shared.maxCarbs.value,
  42. isFocused: $carbsFieldIsFocused,
  43. onValidationError: { message in
  44. alertMessage = message
  45. alertType = .error
  46. showAlert = true
  47. }
  48. )
  49. VStack(alignment: .leading, spacing: 8) {
  50. Text("Food Type")
  51. .font(.headline)
  52. HStack(spacing: 12) {
  53. // Fast carb entry emoji (0.5 hours)
  54. Button(action: {
  55. foodType = "🍭"
  56. absorptionTimeString = "0.5"
  57. }) {
  58. Text("🍭")
  59. .font(.title)
  60. .frame(width: 44, height: 44)
  61. .background(Color.blue.opacity(0.1))
  62. .cornerRadius(8)
  63. }
  64. .buttonStyle(PlainButtonStyle())
  65. // Medium carb entry emoji (3 hours)
  66. Button(action: {
  67. foodType = "🌮"
  68. absorptionTimeString = "3.0"
  69. }) {
  70. Text("🌮")
  71. .font(.title)
  72. .frame(width: 44, height: 44)
  73. .background(Color.blue.opacity(0.1))
  74. .cornerRadius(8)
  75. }
  76. .buttonStyle(PlainButtonStyle())
  77. // Slow carb entry emoji (5 hours)
  78. Button(action: {
  79. foodType = "🍕"
  80. absorptionTimeString = "5.0"
  81. }) {
  82. Text("🍕")
  83. .font(.title)
  84. .frame(width: 44, height: 44)
  85. .background(Color.blue.opacity(0.1))
  86. .cornerRadius(8)
  87. }
  88. .buttonStyle(PlainButtonStyle())
  89. // Custom carb entry emoji (clears and focuses absorption)
  90. Button(action: {
  91. foodType = "🍽️"
  92. absorptionTimeString = ""
  93. absorptionFieldIsFocused = true
  94. }) {
  95. Text("🍽️")
  96. .font(.title)
  97. .frame(width: 44, height: 44)
  98. .background(Color.blue.opacity(0.1))
  99. .cornerRadius(8)
  100. }
  101. .buttonStyle(PlainButtonStyle())
  102. Spacer()
  103. }
  104. }
  105. HStack {
  106. Text("Absorption Time")
  107. Spacer()
  108. TextField("0.0", text: $absorptionTimeString)
  109. .keyboardType(.decimalPad)
  110. .multilineTextAlignment(.trailing)
  111. .focused($absorptionFieldIsFocused)
  112. .onChange(of: absorptionTimeString) { newValue in
  113. // Only allow numbers and decimal point
  114. let filtered = newValue.filter { "0123456789.".contains($0) }
  115. // Ensure only one decimal point
  116. let components = filtered.components(separatedBy: ".")
  117. if components.count > 2 {
  118. absorptionTimeString = String(filtered.dropLast())
  119. } else {
  120. absorptionTimeString = filtered
  121. }
  122. }
  123. Text("hr")
  124. .foregroundColor(.secondary)
  125. }
  126. // Time input section
  127. VStack(alignment: .leading) {
  128. Text("Time")
  129. .font(.headline)
  130. Button(action: {
  131. showDatePickerSheet = true
  132. }) {
  133. HStack {
  134. Text(consumedDate, format: Date.FormatStyle().hour().minute())
  135. .font(.body)
  136. .foregroundColor(.primary)
  137. Spacer()
  138. Image(systemName: "chevron.right")
  139. .font(.caption)
  140. .foregroundColor(.secondary)
  141. }
  142. .padding(.vertical, 8)
  143. .padding(.horizontal, 12)
  144. .background(Color(.systemGray6))
  145. .cornerRadius(8)
  146. }
  147. }
  148. }
  149. Section {
  150. Button(action: sendCarbs) {
  151. if isLoading {
  152. HStack {
  153. ProgressView()
  154. .scaleEffect(0.8)
  155. Text("Sending...")
  156. }
  157. } else {
  158. Text("Send Carbs")
  159. }
  160. }
  161. .disabled(carbsAmount.doubleValue(for: .gram()) <= 0 || isLoading || isTOTPBlocked)
  162. .frame(maxWidth: .infinity)
  163. }
  164. // TOTP Blocking Warning Section
  165. if isTOTPBlocked {
  166. Section {
  167. VStack(alignment: .leading, spacing: 8) {
  168. HStack {
  169. Image(systemName: "exclamationmark.triangle.fill")
  170. .foregroundColor(.orange)
  171. Text("TOTP Code Already Used")
  172. .font(.headline)
  173. .foregroundColor(.orange)
  174. }
  175. Text("This TOTP code has already been used for a command. Please wait for the next code to be generated before sending another command.")
  176. .font(.caption)
  177. .foregroundColor(.secondary)
  178. .multilineTextAlignment(.leading)
  179. }
  180. .padding(.vertical, 4)
  181. }
  182. }
  183. Section(header: Text("Security")) {
  184. VStack(alignment: .leading) {
  185. Text("Current OTP Code")
  186. .font(.headline)
  187. if let otpCode = TOTPGenerator.extractOTPFromURL(Storage.shared.loopAPNSQrCodeURL.value) {
  188. HStack {
  189. Text(otpCode)
  190. .font(.system(.body, design: .monospaced))
  191. .foregroundColor(.green)
  192. .padding(.vertical, 4)
  193. .padding(.horizontal, 8)
  194. .background(Color.green.opacity(0.1))
  195. .cornerRadius(4)
  196. Text("(" + (otpTimeRemaining.map { "\($0)s left" } ?? "-") + ")")
  197. .font(.caption)
  198. .foregroundColor(.secondary)
  199. }
  200. } else {
  201. Text("Invalid QR code URL")
  202. .foregroundColor(.red)
  203. }
  204. }
  205. }
  206. }
  207. .navigationTitle("Carbs")
  208. .navigationBarTitleDisplayMode(.inline)
  209. }
  210. .sheet(isPresented: $showDatePickerSheet) {
  211. VStack {
  212. Text("Consumption Time")
  213. .font(.headline)
  214. .padding()
  215. Form {
  216. DatePicker("Time", selection: $consumedDate, displayedComponents: [.hourAndMinute, .date])
  217. .datePickerStyle(.automatic)
  218. }
  219. }
  220. .presentationDetents([.fraction(1 / 4)])
  221. }
  222. .onAppear {
  223. // Validate APNS setup
  224. let apnsService = LoopAPNSService()
  225. if !apnsService.validateSetup() {
  226. alertMessage = "Loop APNS setup is incomplete. Please configure all required fields in settings."
  227. alertType = .error
  228. showAlert = true
  229. }
  230. // Reset timer state so it shows '-' until first tick
  231. otpTimeRemaining = nil
  232. // Don't reset TOTP usage flag here - let the timer handle it
  233. // Validate TOTP state when view appears
  234. _ = isTOTPBlocked
  235. }
  236. .onReceive(otpTimer) { _ in
  237. let now = Date().timeIntervalSince1970
  238. let newOtpTimeRemaining = Int(otpPeriod - (now.truncatingRemainder(dividingBy: otpPeriod)))
  239. // Check if we've moved to a new TOTP period (when time remaining increases)
  240. if let currentOtpTimeRemaining = otpTimeRemaining,
  241. newOtpTimeRemaining > currentOtpTimeRemaining
  242. {
  243. // New TOTP code generated, reset the usage flag
  244. TOTPService.shared.resetTOTPUsage()
  245. }
  246. // Also check if we're at the very beginning of a new period (when time remaining is close to 30)
  247. if newOtpTimeRemaining >= 29 {
  248. // We're at the start of a new TOTP period, reset the usage flag
  249. TOTPService.shared.resetTOTPUsage()
  250. }
  251. otpTimeRemaining = newOtpTimeRemaining
  252. }
  253. .alert(isPresented: $showAlert) {
  254. switch alertType {
  255. case .success:
  256. return Alert(
  257. title: Text("Success"),
  258. message: Text(alertMessage),
  259. dismissButton: .default(Text("OK")) {
  260. presentationMode.wrappedValue.dismiss()
  261. }
  262. )
  263. case .error:
  264. return Alert(
  265. title: Text("Error"),
  266. message: Text(alertMessage),
  267. dismissButton: .default(Text("OK"))
  268. )
  269. case .confirmation:
  270. let timeFormatter = DateFormatter()
  271. timeFormatter.timeStyle = .short
  272. timeFormatter.dateStyle = .short
  273. return Alert(
  274. title: Text("Confirm Carbs"),
  275. message: Text("Send \(Int(carbsAmount.doubleValue(for: .gram())))g of carbs with \(absorptionTimeString)h absorption time at \(timeFormatter.string(from: consumedDate))?"),
  276. primaryButton: .default(Text("Send")) {
  277. sendCarbsConfirmed()
  278. },
  279. secondaryButton: .cancel()
  280. )
  281. }
  282. }
  283. }
  284. }
  285. private func sendCarbs() {
  286. guard carbsAmount.doubleValue(for: .gram()) > 0 else {
  287. alertMessage = "Please enter a valid carb amount"
  288. alertType = .error
  289. showAlert = true
  290. return
  291. }
  292. // Check guardrails
  293. let maxCarbs = Storage.shared.maxCarbs.value.doubleValue(for: .gram())
  294. let carbsValue = carbsAmount.doubleValue(for: .gram())
  295. if carbsValue > maxCarbs {
  296. alertMessage = "Carbs amount (\(Int(carbsValue))g) exceeds the maximum allowed (\(Int(maxCarbs))g). Please reduce the amount."
  297. alertType = .error
  298. showAlert = true
  299. return
  300. }
  301. // Validate time constraints (similar to LoopCaregiver)
  302. let now = Date()
  303. let maxPastHours = 12
  304. let maxFutureHours = 1
  305. let oldestAcceptedDate = now.addingTimeInterval(-60 * 60 * Double(maxPastHours))
  306. let latestAcceptedDate = now.addingTimeInterval(60 * 60 * Double(maxFutureHours))
  307. if consumedDate < oldestAcceptedDate {
  308. alertMessage = "Time must be within the prior \(maxPastHours) hours"
  309. alertType = .error
  310. showAlert = true
  311. return
  312. }
  313. if consumedDate > latestAcceptedDate {
  314. alertMessage = "Time must be within the next \(maxFutureHours) hour"
  315. alertType = .error
  316. showAlert = true
  317. return
  318. }
  319. alertType = .confirmation
  320. showAlert = true
  321. }
  322. private func sendCarbsConfirmed() {
  323. isLoading = true
  324. // Extract OTP from QR code URL
  325. guard let otpCode = TOTPGenerator.extractOTPFromURL(Storage.shared.loopAPNSQrCodeURL.value) else {
  326. alertMessage = "Invalid QR code URL. Please re-scan the QR code in settings."
  327. alertType = .error
  328. isLoading = false
  329. showAlert = true
  330. return
  331. }
  332. // Parse absorption time string to double
  333. guard let absorptionTimeValue = Double(absorptionTimeString), absorptionTimeValue >= 0.5, absorptionTimeValue <= 8.0 else {
  334. alertMessage = "Please enter a valid absorption time between 0.5 and 8.0 hours"
  335. alertType = .error
  336. isLoading = false
  337. showAlert = true
  338. return
  339. }
  340. // Create the APNS payload for carbs with custom time
  341. // We "randomize" the milliseconds to avoid issue with NS which
  342. // doesn't allow entries at the same second.
  343. let adjustedConsumedDate = consumedDate.dateUsingCurrentSeconds()
  344. let payload = LoopAPNSPayload(
  345. type: .carbs,
  346. carbsAmount: carbsAmount.doubleValue(for: .gram()),
  347. absorptionTime: absorptionTimeValue,
  348. foodType: foodType.isEmpty ? nil : foodType,
  349. consumedDate: adjustedConsumedDate,
  350. otp: otpCode
  351. )
  352. let apnsService = LoopAPNSService()
  353. apnsService.sendCarbsViaAPNS(payload: payload) { success, errorMessage in
  354. DispatchQueue.main.async {
  355. self.isLoading = false
  356. if success {
  357. // Mark TOTP code as used
  358. TOTPService.shared.markTOTPAsUsed(qrCodeURL: Storage.shared.loopAPNSQrCodeURL.value)
  359. let timeFormatter = DateFormatter()
  360. timeFormatter.timeStyle = .short
  361. self.alertMessage = "Carbs sent successfully for \(timeFormatter.string(from: adjustedConsumedDate))!"
  362. self.alertType = .success
  363. LogManager.shared.log(
  364. category: .apns,
  365. message: "Carbs sent - Amount: \(carbsAmount.doubleValue(for: .gram()))g, Absorption: \(absorptionTimeString)h, Time: \(adjustedConsumedDate)"
  366. )
  367. } else {
  368. self.alertMessage = errorMessage ?? "Failed to send carbs. Check your Loop APNS configuration."
  369. self.alertType = .error
  370. LogManager.shared.log(
  371. category: .apns,
  372. message: "Failed to send carbs: \(errorMessage ?? "unknown error")"
  373. )
  374. }
  375. self.showAlert = true
  376. }
  377. }
  378. }
  379. }
  380. // APNS Payload structure for carbs
  381. struct LoopAPNSPayload {
  382. enum PayloadType {
  383. case carbs
  384. case bolus
  385. }
  386. let type: PayloadType
  387. let carbsAmount: Double?
  388. let absorptionTime: Double?
  389. let foodType: String?
  390. let bolusAmount: Double?
  391. let consumedDate: Date?
  392. let otp: String
  393. init(type: PayloadType, carbsAmount: Double? = nil, absorptionTime: Double? = nil, foodType: String? = nil, bolusAmount: Double? = nil, consumedDate: Date? = nil, otp: String) {
  394. self.type = type
  395. self.carbsAmount = carbsAmount
  396. self.absorptionTime = absorptionTime
  397. self.foodType = foodType
  398. self.bolusAmount = bolusAmount
  399. self.consumedDate = consumedDate
  400. self.otp = otp
  401. }
  402. }