MealView.swift 13 KB

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