LoopAPNSCarbsView.swift 21 KB

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