MealView.swift 14 KB

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