MainView.swift 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309
  1. import HealthKit
  2. import SwiftDate
  3. import SwiftUI
  4. struct MainView: View {
  5. private enum Config {
  6. static let lag: TimeInterval = 30
  7. }
  8. @EnvironmentObject var state: WatchStateModel
  9. @State var isCarbsActive = false
  10. @State var isTargetsActive = false
  11. @State var isBolusActive = false
  12. @State private var pulse = 0
  13. @GestureState var isDetectingLongPress = false
  14. @State var completedLongPress = false
  15. private var healthStore = HKHealthStore()
  16. let heartRateQuantity = HKUnit(from: "count/min")
  17. var body: some View {
  18. ZStack(alignment: .topLeading) {
  19. if state.timerDate.timeIntervalSince(state.lastUpdate) > 10 {
  20. HStack {
  21. withAnimation {
  22. BlinkingView(count: 5, size: 3)
  23. .frame(width: 14, height: 14)
  24. .padding(2)
  25. }
  26. Text("Updating...").font(.caption2).foregroundColor(.secondary)
  27. }
  28. }
  29. VStack {
  30. header
  31. Spacer()
  32. buttons
  33. }
  34. if state.isConfirmationViewActive {
  35. ConfirmationView(success: $state.confirmationSuccess)
  36. .background(Rectangle().fill(.black))
  37. }
  38. if state.isConfirmationBolusViewActive {
  39. BolusConfirmationView()
  40. .environmentObject(state)
  41. .background(Rectangle().fill(.black))
  42. }
  43. }
  44. .frame(maxHeight: .infinity)
  45. .padding()
  46. .onReceive(state.timer) { date in
  47. state.timerDate = date
  48. state.requestState()
  49. }
  50. .onAppear {
  51. state.requestState()
  52. }
  53. }
  54. var header: some View {
  55. VStack {
  56. HStack(alignment: .top) {
  57. VStack(alignment: .leading) {
  58. HStack {
  59. Text(state.glucose).font(.title)
  60. Text(state.trend)
  61. .scaledToFill()
  62. .minimumScaleFactor(0.5)
  63. }
  64. Text(state.delta).font(.caption2).foregroundColor(.gray)
  65. }
  66. Spacer()
  67. VStack(spacing: 0) {
  68. HStack {
  69. Circle().stroke(color, lineWidth: 5).frame(width: 26, height: 26).padding(10)
  70. }
  71. if state.lastLoopDate != nil {
  72. Text(timeString).font(.caption2).foregroundColor(.gray)
  73. } else {
  74. Text("--").font(.caption2).foregroundColor(.gray)
  75. }
  76. }
  77. }
  78. Spacer()
  79. HStack(alignment: .firstTextBaseline) {
  80. Text(iobFormatter.string(from: (state.cob ?? 0) as NSNumber)!)
  81. .font(.caption2)
  82. .scaledToFill()
  83. .foregroundColor(Color.white)
  84. .minimumScaleFactor(0.5)
  85. Text("g").foregroundColor(.loopGreen)
  86. .font(.caption2)
  87. .scaledToFill()
  88. .foregroundColor(.loopGreen)
  89. .minimumScaleFactor(0.5)
  90. Spacer()
  91. Text(iobFormatter.string(from: (state.iob ?? 0) as NSNumber)!)
  92. .font(.caption2)
  93. .scaledToFill()
  94. .foregroundColor(Color.white)
  95. .minimumScaleFactor(0.5)
  96. Text("U").foregroundColor(.insulin)
  97. .font(.caption2)
  98. .scaledToFill()
  99. .foregroundColor(.loopGreen)
  100. .minimumScaleFactor(0.5)
  101. if state.displayHR {
  102. Spacer()
  103. HStack {
  104. if completedLongPress {
  105. HStack {
  106. Text("❤️" + " \(pulse)")
  107. .fontWeight(.regular)
  108. .font(.custom("activated", size: 20))
  109. .scaledToFill()
  110. .foregroundColor(.white)
  111. .minimumScaleFactor(0.5)
  112. }
  113. .scaleEffect(isDetectingLongPress ? 3 : 1)
  114. .gesture(longPress)
  115. } else {
  116. HStack {
  117. Text("❤️" + " \(pulse)")
  118. .fontWeight(.regular)
  119. .font(.caption2)
  120. .scaledToFill()
  121. .foregroundColor(.white)
  122. .minimumScaleFactor(0.5)
  123. }
  124. .scaleEffect(isDetectingLongPress ? 3 : 1)
  125. .gesture(longPress)
  126. }
  127. }
  128. } else if let eventualBG = state.eventualBG.nonEmpty {
  129. Spacer()
  130. HStack {
  131. Text(eventualBG)
  132. .font(.caption2)
  133. .scaledToFill()
  134. .foregroundColor(.secondary)
  135. .minimumScaleFactor(0.5)
  136. }
  137. }
  138. }
  139. Spacer()
  140. .onAppear(perform: start)
  141. }.padding()
  142. }
  143. var longPress: some Gesture {
  144. LongPressGesture(minimumDuration: 1)
  145. .updating($isDetectingLongPress) { currentState, gestureState,
  146. _ in
  147. gestureState = currentState
  148. }
  149. .onEnded { _ in
  150. if completedLongPress {
  151. completedLongPress = false
  152. } else { completedLongPress = true }
  153. }
  154. }
  155. var buttons: some View {
  156. HStack(alignment: .center) {
  157. NavigationLink(isActive: $state.isCarbsViewActive) {
  158. CarbsView()
  159. .environmentObject(state)
  160. } label: {
  161. Image("carbs", bundle: nil)
  162. .renderingMode(.template)
  163. .resizable()
  164. .frame(width: 24, height: 24)
  165. .foregroundColor(.loopGreen)
  166. }
  167. NavigationLink(isActive: $state.isTempTargetViewActive) {
  168. TempTargetsView()
  169. .environmentObject(state)
  170. } label: {
  171. VStack {
  172. Image("target", bundle: nil)
  173. .renderingMode(.template)
  174. .resizable()
  175. .frame(width: 24, height: 24)
  176. .foregroundColor(.loopYellow)
  177. if let until = state.tempTargets.compactMap(\.until).first, until > Date() {
  178. Text(until, style: .timer)
  179. .scaledToFill()
  180. .font(.system(size: 8))
  181. }
  182. }
  183. }
  184. NavigationLink(isActive: $state.isBolusViewActive) {
  185. BolusView()
  186. .environmentObject(state)
  187. } label: {
  188. Image("bolus", bundle: nil)
  189. .renderingMode(.template)
  190. .resizable()
  191. .frame(width: 24, height: 24)
  192. .foregroundColor(.insulin)
  193. }
  194. }
  195. }
  196. func start() {
  197. autorizeHealthKit()
  198. startHeartRateQuery(quantityTypeIdentifier: .heartRate)
  199. }
  200. func autorizeHealthKit() {
  201. let healthKitTypes: Set = [
  202. HKObjectType.quantityType(forIdentifier: HKQuantityTypeIdentifier.heartRate)!
  203. ]
  204. healthStore.requestAuthorization(toShare: healthKitTypes, read: healthKitTypes) { _, _ in }
  205. }
  206. private func startHeartRateQuery(quantityTypeIdentifier: HKQuantityTypeIdentifier) {
  207. let devicePredicate = HKQuery.predicateForObjects(from: [HKDevice.local()])
  208. let updateHandler: (HKAnchoredObjectQuery, [HKSample]?, [HKDeletedObject]?, HKQueryAnchor?, Error?) -> Void = {
  209. _, samples, _, _, _ in
  210. guard let samples = samples as? [HKQuantitySample] else {
  211. return
  212. }
  213. self.process(samples, type: quantityTypeIdentifier)
  214. }
  215. let query = HKAnchoredObjectQuery(
  216. type: HKObjectType.quantityType(forIdentifier: quantityTypeIdentifier)!,
  217. predicate: devicePredicate,
  218. anchor: nil,
  219. limit: HKObjectQueryNoLimit,
  220. resultsHandler: updateHandler
  221. )
  222. query.updateHandler = updateHandler
  223. healthStore.execute(query)
  224. }
  225. private func process(_ samples: [HKQuantitySample], type: HKQuantityTypeIdentifier) {
  226. var lastHeartRate = 0.0
  227. for sample in samples {
  228. if type == .heartRate {
  229. lastHeartRate = sample.quantity.doubleValue(for: heartRateQuantity)
  230. }
  231. pulse = Int(lastHeartRate)
  232. }
  233. }
  234. private var iobFormatter: NumberFormatter {
  235. let formatter = NumberFormatter()
  236. formatter.maximumFractionDigits = 2
  237. formatter.numberStyle = .decimal
  238. return formatter
  239. }
  240. private var timeString: String {
  241. let minAgo = Int((Date().timeIntervalSince(state.lastLoopDate ?? .distantPast) - Config.lag) / 60) + 1
  242. if minAgo > 1440 {
  243. return "--"
  244. }
  245. return "\(minAgo) " + NSLocalizedString("min", comment: "Minutes ago since last loop")
  246. }
  247. private var color: Color {
  248. guard let lastLoopDate = state.lastLoopDate else {
  249. return .loopGray
  250. }
  251. let delta = Date().timeIntervalSince(lastLoopDate) - Config.lag
  252. if delta <= 5.minutes.timeInterval {
  253. return .loopGreen
  254. } else if delta <= 10.minutes.timeInterval {
  255. return .loopYellow
  256. } else {
  257. return .loopRed
  258. }
  259. }
  260. }
  261. struct ContentView_Previews: PreviewProvider {
  262. static var previews: some View {
  263. let state = WatchStateModel()
  264. state.glucose = "15,8"
  265. state.delta = "+888"
  266. state.iob = 100.38
  267. state.cob = 112.123
  268. state.lastLoopDate = Date().addingTimeInterval(-200)
  269. state
  270. .tempTargets =
  271. [TempTargetWatchPreset(name: "Test", id: "test", description: "", until: Date().addingTimeInterval(3600 * 3))]
  272. return Group {
  273. MainView()
  274. MainView().previewDevice("Apple Watch Series 5 - 40mm")
  275. MainView().previewDevice("Apple Watch Series 3 - 38mm")
  276. }.environmentObject(state)
  277. }
  278. }