MealView.swift 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344
  1. // LoopFollow
  2. // MealView.swift
  3. import HealthKit
  4. import LocalAuthentication
  5. import SwiftUI
  6. struct MealView: View {
  7. @Environment(\.presentationMode) private var presentationMode
  8. @State private var carbs = HKQuantity(unit: .gram(), doubleValue: 0.0)
  9. @State private var protein = HKQuantity(unit: .gram(), doubleValue: 0.0)
  10. @State private var fat = HKQuantity(unit: .gram(), doubleValue: 0.0)
  11. @State private var bolusAmount = HKQuantity(unit: .internationalUnit(), doubleValue: 0.0)
  12. private let pushNotificationManager = PushNotificationManager()
  13. @ObservedObject private var maxCarbs = Storage.shared.maxCarbs
  14. @ObservedObject private var maxProtein = Storage.shared.maxProtein
  15. @ObservedObject private var maxFat = Storage.shared.maxFat
  16. @ObservedObject private var mealWithBolus = Storage.shared.mealWithBolus
  17. @ObservedObject private var mealWithFatProtein = Storage.shared.mealWithFatProtein
  18. @ObservedObject private var maxBolus = Storage.shared.maxBolus
  19. @FocusState private var carbsFieldIsFocused: Bool
  20. @FocusState private var proteinFieldIsFocused: Bool
  21. @FocusState private var fatFieldIsFocused: Bool
  22. @FocusState private var bolusFieldIsFocused: Bool
  23. @State private var showAlert: Bool = false
  24. @State private var alertType: AlertType? = nil
  25. @State private var alertMessage: String? = nil
  26. @State private var isLoading: Bool = false
  27. @State private var statusMessage: String? = nil
  28. @State private var selectedTime: Date? = nil
  29. @State private var isScheduling: Bool = false
  30. enum AlertType {
  31. case confirmMeal
  32. case statusSuccess
  33. case statusFailure
  34. case validationError
  35. }
  36. var body: some View {
  37. NavigationView {
  38. VStack {
  39. Form {
  40. Section(header: Text("Meal Data")) {
  41. HKQuantityInputView(
  42. label: "Carbs",
  43. quantity: $carbs,
  44. unit: .gram(),
  45. maxLength: 4,
  46. minValue: HKQuantity(unit: .gram(), doubleValue: 0),
  47. maxValue: maxCarbs.value,
  48. isFocused: $carbsFieldIsFocused,
  49. onValidationError: { message in
  50. handleValidationError(message)
  51. }
  52. )
  53. if mealWithFatProtein.value {
  54. HKQuantityInputView(
  55. label: "Protein",
  56. quantity: $protein,
  57. unit: .gram(),
  58. maxLength: 4,
  59. minValue: HKQuantity(unit: .gram(), doubleValue: 0),
  60. maxValue: maxProtein.value,
  61. isFocused: $proteinFieldIsFocused,
  62. onValidationError: { message in
  63. handleValidationError(message)
  64. }
  65. )
  66. HKQuantityInputView(
  67. label: "Fat",
  68. quantity: $fat,
  69. unit: .gram(),
  70. maxLength: 4,
  71. minValue: HKQuantity(unit: .gram(), doubleValue: 0),
  72. maxValue: maxFat.value,
  73. isFocused: $fatFieldIsFocused,
  74. onValidationError: { message in
  75. handleValidationError(message)
  76. }
  77. )
  78. }
  79. if mealWithBolus.value {
  80. HKQuantityInputView(
  81. label: "Bolus Amount",
  82. quantity: $bolusAmount,
  83. unit: .internationalUnit(),
  84. maxLength: 4,
  85. minValue: HKQuantity(unit: .internationalUnit(), doubleValue: 0.05),
  86. maxValue: maxBolus.value,
  87. isFocused: $bolusFieldIsFocused,
  88. onValidationError: { message in
  89. handleValidationError(message)
  90. }
  91. )
  92. }
  93. }
  94. Section(header: Text("Schedule")) {
  95. Toggle("Schedule for later", isOn: $isScheduling)
  96. if isScheduling {
  97. DatePicker(
  98. "Select Time",
  99. selection: Binding(
  100. get: { self.selectedTime ?? Date() },
  101. set: { self.selectedTime = $0 }
  102. ),
  103. displayedComponents: .hourAndMinute
  104. )
  105. .datePickerStyle(CompactDatePickerStyle())
  106. if bolusAmount.doubleValue(for: .internationalUnit()) > 0 {
  107. Text("Note: The meal will be scheduled, but the bolus is enacted immediately.")
  108. }
  109. }
  110. }
  111. LoadingButtonView(
  112. buttonText: "Send Meal",
  113. progressText: "Sending Meal Data...",
  114. isLoading: isLoading,
  115. action: {
  116. carbsFieldIsFocused = false
  117. proteinFieldIsFocused = false
  118. fatFieldIsFocused = false
  119. DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) {
  120. guard carbs.doubleValue(for: .gram()) != 0 ||
  121. protein.doubleValue(for: .gram()) != 0 ||
  122. fat.doubleValue(for: .gram()) != 0
  123. else {
  124. return
  125. }
  126. if !showAlert {
  127. alertType = .confirmMeal
  128. showAlert = true
  129. }
  130. }
  131. },
  132. isDisabled: isButtonDisabled
  133. )
  134. }
  135. .navigationTitle("Meal")
  136. .navigationBarTitleDisplayMode(.inline)
  137. }
  138. .onAppear {
  139. selectedTime = nil
  140. isScheduling = false
  141. }
  142. .alert(isPresented: $showAlert) {
  143. switch alertType {
  144. case .confirmMeal:
  145. let carbsAmount = carbs.doubleValue(for: HKUnit.gram())
  146. let proteinAmount = protein.doubleValue(for: HKUnit.gram())
  147. let fatAmount = fat.doubleValue(for: HKUnit.gram())
  148. let bolusAmount = bolusAmount.doubleValue(for: .internationalUnit())
  149. var message = "Are you sure you want to send the meal data"
  150. if let selectedTime = selectedTime {
  151. let timeFormatter = DateFormatter()
  152. timeFormatter.timeStyle = .short
  153. let timeString = timeFormatter.string(from: selectedTime)
  154. message += " for \(timeString)?"
  155. } else {
  156. message += " now?"
  157. }
  158. if carbsAmount > 0 {
  159. message += String(format: "\nCarbs: %.0f g", carbsAmount)
  160. }
  161. if proteinAmount > 0 {
  162. message += String(format: "\nProtein: %.0f g", proteinAmount)
  163. }
  164. if fatAmount > 0 {
  165. message += String(format: "\nFat: %.0f g", fatAmount)
  166. }
  167. if bolusAmount > 0 {
  168. message += String(format: "\nBolus: %.2f U", bolusAmount)
  169. }
  170. return Alert(
  171. title: Text("Confirm Meal"),
  172. message: Text(message),
  173. primaryButton: .default(Text("Confirm"), action: {
  174. DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) {
  175. if bolusAmount > 0 {
  176. authenticateUser { success in
  177. if success {
  178. sendMealCommand()
  179. }
  180. }
  181. } else {
  182. sendMealCommand()
  183. }
  184. }
  185. }),
  186. secondaryButton: .cancel()
  187. )
  188. case .statusSuccess:
  189. return Alert(
  190. title: Text("Status"),
  191. message: Text(statusMessage ?? ""),
  192. dismissButton: .default(Text("OK"), action: {
  193. presentationMode.wrappedValue.dismiss()
  194. })
  195. )
  196. case .statusFailure:
  197. return Alert(
  198. title: Text("Status"),
  199. message: Text(statusMessage ?? ""),
  200. dismissButton: .default(Text("OK"))
  201. )
  202. case .validationError:
  203. return Alert(
  204. title: Text("Validation Error"),
  205. message: Text(alertMessage ?? ""),
  206. dismissButton: .default(Text("OK"))
  207. )
  208. case .none:
  209. return Alert(title: Text("Unknown Alert"))
  210. }
  211. }
  212. }
  213. }
  214. private var isButtonDisabled: Bool {
  215. return isLoading
  216. }
  217. private func sendMealCommand() {
  218. isLoading = true
  219. var scheduledDate: Date? = nil
  220. if isScheduling, let selectedTime = selectedTime {
  221. let calendar = Calendar.current
  222. let now = Date()
  223. let selectedDateComponents = calendar.dateComponents([.hour, .minute], from: selectedTime)
  224. let currentSecond = calendar.component(.second, from: now)
  225. scheduledDate = calendar.date(bySettingHour: selectedDateComponents.hour ?? 0,
  226. minute: selectedDateComponents.minute ?? 0,
  227. second: currentSecond,
  228. of: now) ?? now
  229. }
  230. pushNotificationManager.sendMealPushNotification(
  231. carbs: carbs,
  232. protein: protein,
  233. fat: fat,
  234. bolusAmount: bolusAmount,
  235. scheduledTime: scheduledDate
  236. ) { success, errorMessage in
  237. DispatchQueue.main.async {
  238. isLoading = false
  239. if success {
  240. statusMessage = "Meal command sent successfully."
  241. LogManager.shared.log(
  242. category: .apns,
  243. message: "sendMealPushNotification succeeded - Carbs: \(carbs.doubleValue(for: .gram())) g, Protein: \(protein.doubleValue(for: .gram())) g, Fat: \(fat.doubleValue(for: .gram())) g, Bolus: \(bolusAmount.doubleValue(for: .internationalUnit())) U, Scheduled: \(scheduledDate != nil ? formatDate(scheduledDate!) : "now")"
  244. )
  245. // Reset meal values and scheduled data after success
  246. carbs = HKQuantity(unit: .gram(), doubleValue: 0.0)
  247. protein = HKQuantity(unit: .gram(), doubleValue: 0.0)
  248. fat = HKQuantity(unit: .gram(), doubleValue: 0.0)
  249. selectedTime = nil
  250. isScheduling = false
  251. alertType = .statusSuccess
  252. } else {
  253. statusMessage = errorMessage ?? "Failed to send meal command."
  254. LogManager.shared.log(
  255. category: .apns,
  256. message: "sendMealPushNotification failed with error: \(errorMessage ?? "unknown error")"
  257. )
  258. alertType = .statusFailure
  259. }
  260. showAlert = true
  261. }
  262. }
  263. }
  264. private func formatDate(_ date: Date) -> String {
  265. let formatter = DateFormatter()
  266. formatter.timeStyle = .short
  267. return formatter.string(from: date)
  268. }
  269. private func handleValidationError(_ message: String) {
  270. alertMessage = message
  271. alertType = .validationError
  272. showAlert = true
  273. }
  274. private func authenticateUser(completion: @escaping (Bool) -> Void) {
  275. let context = LAContext()
  276. var error: NSError?
  277. let reason = "Confirm your identity to send bolus."
  278. if context.canEvaluatePolicy(.deviceOwnerAuthenticationWithBiometrics, error: &error) {
  279. context.evaluatePolicy(.deviceOwnerAuthenticationWithBiometrics, localizedReason: reason) { success, _ in
  280. DispatchQueue.main.async {
  281. if success {
  282. completion(true)
  283. } else {
  284. // Biometric failed, try passcode
  285. self.tryPasscode(completion: completion)
  286. }
  287. }
  288. }
  289. } else {
  290. // No biometrics available, try passcode directly
  291. tryPasscode(completion: completion)
  292. }
  293. }
  294. private func tryPasscode(completion: @escaping (Bool) -> Void) {
  295. let context = LAContext()
  296. var error: NSError?
  297. let reason = "Confirm your identity to send bolus."
  298. if context.canEvaluatePolicy(.deviceOwnerAuthentication, error: &error) {
  299. context.evaluatePolicy(.deviceOwnerAuthentication, localizedReason: reason) { success, _ in
  300. DispatchQueue.main.async {
  301. completion(success)
  302. }
  303. }
  304. } else {
  305. DispatchQueue.main.async {
  306. completion(false)
  307. }
  308. }
  309. }
  310. }