TrioMainWatchView.swift 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281
  1. import Charts
  2. import SwiftUI
  3. import WatchKit
  4. struct TrioMainWatchView: View {
  5. @State private var state = WatchState()
  6. // misc
  7. @State private var currentPage: Int = 0
  8. @State private var rotationDegrees: Double = 0.0
  9. @State private var showingTempTargetSheet = false
  10. // view visbility
  11. @State private var showingTreatmentMenuSheet: Bool = false
  12. @State private var showingOverrideSheet: Bool = false
  13. // navigation flag for meal bolus combo
  14. @State private var continueToBolus = false
  15. @State private var navigationPath = NavigationPath()
  16. // treatments
  17. @State private var selectedTreatment: TreatmentOption?
  18. var isWatchStateDated: Bool {
  19. // If `lastWatchStateUpdate` is nil, treat as "dated"
  20. guard let lastUpdateTimestamp = state.lastWatchStateUpdate else {
  21. return true
  22. }
  23. let now = Date().timeIntervalSince1970
  24. let secondsSinceUpdate = now - lastUpdateTimestamp
  25. // Return true if last update older than 5 min, so 1 loop cycle
  26. return secondsSinceUpdate > 5 * 60
  27. }
  28. var isSessionUnreachable: Bool {
  29. guard let session = state.session else {
  30. return true // No session at all => unreachable
  31. }
  32. // Return true if not .activated OR not reachable
  33. return session.activationState != .activated
  34. }
  35. // Active adjustment indicator
  36. private func isAdjustmentActive<T>(for presets: [T], predicate: (T) -> Bool) -> Bool {
  37. let sortedPresets = presets.sorted { predicate($0) && !predicate($1) }
  38. return !sortedPresets.isEmpty && sortedPresets.first(where: predicate) != nil
  39. }
  40. private var isTempTargetActive: Bool {
  41. isAdjustmentActive(for: state.tempTargetPresets) { $0.isEnabled }
  42. }
  43. private var isOverrideActive: Bool {
  44. isAdjustmentActive(for: state.overridePresets) { $0.isEnabled }
  45. }
  46. private var trioBackgroundColor = LinearGradient(
  47. gradient: Gradient(colors: [Color.bgDarkBlue, Color.bgDarkerDarkBlue]),
  48. startPoint: .top,
  49. endPoint: .bottom
  50. )
  51. var body: some View {
  52. NavigationStack(path: $navigationPath) {
  53. TabView(selection: $currentPage) {
  54. // Page 1: Current glucose trend in "BG bobble"
  55. ZStack {
  56. GlucoseTrendView(
  57. state: state,
  58. rotationDegrees: rotationDegrees,
  59. isWatchStateDated: isWatchStateDated || isSessionUnreachable
  60. )
  61. if state.showSyncingAnimation {
  62. Image(systemName: "iphone.radiowaves.left.and.right")
  63. .symbolRenderingMode(.palette)
  64. .foregroundStyle(Color.primary, Color.tabBar, Color.clear)
  65. .symbolEffect(
  66. .variableColor.iterative,
  67. options: .repeating,
  68. value: state.showSyncingAnimation
  69. )
  70. .position(
  71. x: 20,
  72. y: (WKInterfaceDevice.current().screenBounds.height / 4) -
  73. 7 // Font .body == 14, so half of default size for the SF Symbol image
  74. )
  75. }
  76. }.tag(0)
  77. // Page 2: Glucose chart
  78. GlucoseChartView(
  79. glucoseValues: state.glucoseValues,
  80. minYAxisValue: state.minYAxisValue,
  81. maxYAxisValue: state.maxYAxisValue
  82. )
  83. .tag(1)
  84. }
  85. .onAppear {
  86. // Hard reset variables when main view appears
  87. /// Reset `bolusProgress` and `activeBolusAmount` to ensure no stale bolus progressbar is stuck on home view
  88. state.bolusProgress = 0
  89. state.activeBolusAmount = 0
  90. /// Reset `bolusAmount` and `recommendedBolus` to ensure no stale / old value is set when user opens bolus input or meal combo the next time.
  91. state.bolusAmount = 0
  92. state.recommendedBolus = 0
  93. }
  94. .background(trioBackgroundColor)
  95. .tabViewStyle(.verticalPage)
  96. .digitalCrownRotation($currentPage.doubleBinding(), from: 0, through: 1, by: 1)
  97. .onChange(of: state.trend) { _, newTrend in
  98. withAnimation {
  99. updateRotation(for: newTrend)
  100. }
  101. }
  102. .toolbar {
  103. ToolbarItem(placement: .topBarLeading) {
  104. HStack {
  105. Image(systemName: "syringe.fill")
  106. .foregroundStyle(Color.insulin)
  107. Text(isWatchStateDated || isSessionUnreachable ? "--" : state.iob ?? "--")
  108. .foregroundStyle(isWatchStateDated ? Color.secondary : Color.white)
  109. }.font(.caption2)
  110. }
  111. ToolbarItem(placement: .topBarTrailing) {
  112. HStack {
  113. Text(isWatchStateDated || isSessionUnreachable ? "--" : state.cob ?? "--")
  114. .foregroundStyle(isWatchStateDated || isSessionUnreachable ? Color.secondary : Color.white)
  115. Image(systemName: "fork.knife")
  116. .foregroundStyle(Color.orange)
  117. }.font(.caption2)
  118. }
  119. ToolbarItemGroup(placement: .bottomBar) {
  120. Button {
  121. showingOverrideSheet = true
  122. } label: {
  123. Image(systemName: "clock.arrow.2.circlepath")
  124. .foregroundStyle(Color.primary, isOverrideActive ? Color.primary : Color.purple)
  125. }
  126. .tint(isOverrideActive ? Color.purple : nil)
  127. .disabled(isWatchStateDated || isSessionUnreachable)
  128. Button {
  129. showingTreatmentMenuSheet = true
  130. } label: {
  131. Image(systemName: "plus")
  132. .foregroundStyle(Color.bgDarkerDarkBlue)
  133. }
  134. .controlSize(.large)
  135. .buttonStyle(WatchOSButtonStyle(deviceType: state.deviceType))
  136. .disabled(isWatchStateDated || isSessionUnreachable)
  137. Button {
  138. showingTempTargetSheet = true
  139. } label: {
  140. Image(systemName: "target")
  141. .foregroundStyle(isTempTargetActive ? Color.primary : Color.loopGreen.opacity(0.75))
  142. }
  143. .tint(isTempTargetActive ? Color.loopGreen.opacity(0.75) : nil)
  144. .disabled(isWatchStateDated || isSessionUnreachable)
  145. }
  146. }
  147. .fullScreenCover(isPresented: $showingTreatmentMenuSheet) {
  148. TreatmentMenuView(deviceType: state.deviceType, selectedTreatment: $selectedTreatment) {
  149. handleTreatmentSelection()
  150. }
  151. .onAppear {
  152. // reset the conditional navigation flag when opening
  153. continueToBolus = false
  154. }
  155. }
  156. .sheet(isPresented: $showingOverrideSheet) {
  157. OverridePresetsView(
  158. state: state,
  159. overridePresets: state.overridePresets
  160. ) {
  161. showingOverrideSheet = false
  162. navigationPath.append(NavigationDestinations.acknowledgmentPending)
  163. }
  164. }
  165. .sheet(isPresented: $showingTempTargetSheet) {
  166. TempTargetPresetsView(
  167. state: state,
  168. tempTargetPresets: state.tempTargetPresets
  169. ) {
  170. showingTempTargetSheet = false
  171. navigationPath.append(NavigationDestinations.acknowledgmentPending)
  172. }
  173. }
  174. .navigationDestination(for: NavigationDestinations.self) { destination in
  175. switch destination {
  176. case .acknowledgmentPending:
  177. AcknowledgementPendingView(
  178. navigationPath: $navigationPath,
  179. state: state,
  180. shouldNavigateToRoot: $state.shouldNavigateToRoot
  181. )
  182. case .carbsInput:
  183. CarbsInputView(
  184. navigationPath: $navigationPath,
  185. state: state,
  186. continueToBolus: continueToBolus
  187. )
  188. case .bolusInput:
  189. BolusInputView(
  190. navigationPath: $navigationPath,
  191. state: state
  192. )
  193. case .bolusConfirm:
  194. BolusConfirmationView(
  195. navigationPath: $navigationPath,
  196. state: state,
  197. bolusAmount: $state.bolusAmount,
  198. confirmationProgress: $state.confirmationProgress
  199. )
  200. }
  201. }
  202. .onChange(of: navigationPath) { _, newPath in
  203. if newPath.isEmpty {
  204. // Reset conditional view navigation when returning to root view
  205. continueToBolus = false
  206. }
  207. }
  208. }
  209. .ignoresSafeArea()
  210. .blur(radius: state.showBolusProgressOverlay ? 3 : 0)
  211. .overlay {
  212. if state.showBolusProgressOverlay {
  213. BolusProgressOverlay(state: state) {
  214. state.shouldNavigateToRoot = false
  215. navigationPath.append(NavigationDestinations.acknowledgmentPending)
  216. }.transition(.opacity)
  217. }
  218. }
  219. }
  220. private func updateRotation(for trend: String?) {
  221. switch trend {
  222. case "DoubleUp",
  223. "SingleUp":
  224. rotationDegrees = -90
  225. case "FortyFiveUp":
  226. rotationDegrees = -45
  227. case "Flat":
  228. rotationDegrees = 0
  229. case "FortyFiveDown":
  230. rotationDegrees = 45
  231. case "DoubleDown",
  232. "SingleDown":
  233. rotationDegrees = 90
  234. default:
  235. rotationDegrees = 0
  236. }
  237. }
  238. private func handleTreatmentSelection() {
  239. showingTreatmentMenuSheet = false // Dismiss the sheet
  240. guard let treatment = selectedTreatment else { return }
  241. switch treatment {
  242. case .meal:
  243. navigationPath.append(NavigationDestinations.carbsInput)
  244. case .bolus:
  245. // Reset carbs amount when directly going to bolus input
  246. state.carbsAmount = 0
  247. navigationPath.append(NavigationDestinations.bolusInput)
  248. case .mealBolusCombo:
  249. continueToBolus = true // Explicitely set subsequent view navigation
  250. navigationPath.append(NavigationDestinations.carbsInput)
  251. }
  252. }
  253. }
  254. #Preview {
  255. TrioMainWatchView()
  256. }