StatRootView.swift 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383
  1. import Charts
  2. import SwiftDate
  3. import SwiftUI
  4. import Swinject
  5. extension Stat {
  6. struct RootView: BaseView {
  7. enum Constants {
  8. static let spacing: CGFloat = 16
  9. static let cornerRadius: CGFloat = 10
  10. static let backgroundOpacity = 0.1
  11. }
  12. let resolver: Resolver
  13. @Environment(\.colorScheme) var colorScheme
  14. @Environment(AppState.self) var appState
  15. @State var state = StateModel()
  16. @State private var selectedView: StateModel.StatisticViewType = .glucose
  17. var body: some View {
  18. VStack {
  19. Picker("View", selection: $selectedView) {
  20. ForEach(StateModel.StatisticViewType.allCases) { viewType in
  21. Text(viewType.displayName).tag(viewType)
  22. }
  23. }
  24. .pickerStyle(.segmented)
  25. .padding(.horizontal)
  26. ScrollView {
  27. VStack(spacing: Constants.spacing) {
  28. switch selectedView {
  29. case .glucose:
  30. glucoseView
  31. case .insulin:
  32. insulinView
  33. case .looping:
  34. loopingView
  35. case .meals:
  36. mealsView
  37. }
  38. }
  39. .padding()
  40. }
  41. }
  42. .background(appState.trioBackgroundColor(for: colorScheme))
  43. .onAppear(perform: configureView)
  44. .navigationBarTitleDisplayMode(.inline)
  45. .navigationTitle("Statistics")
  46. .toolbar {
  47. ToolbarItem(placement: .topBarLeading) {
  48. Button(action: state.hideModal) {
  49. Text("Close")
  50. .foregroundColor(.tabBar)
  51. }
  52. }
  53. }
  54. }
  55. // MARK: - Stats View
  56. @ViewBuilder var glucoseView: some View {
  57. HStack {
  58. Text("Chart Type")
  59. .font(.headline)
  60. Spacer()
  61. Picker("Glucose Chart Type", selection: $state.selectedGlucoseChartType) {
  62. ForEach(StateModel.GlucoseChartType.allCases, id: \.self) { type in
  63. Text(type.displayName)
  64. }
  65. }
  66. .pickerStyle(.menu)
  67. }.padding(.horizontal)
  68. Picker("Duration", selection: $state.selectedIntervalForGlucoseStats) {
  69. ForEach(StateModel.StatsTimeIntervalWithToday.allCases, id: \.self) { timeInterval in
  70. Text(timeInterval.displayName)
  71. }
  72. }
  73. .pickerStyle(.segmented)
  74. if state.glucoseFromPersistence.isEmpty {
  75. ContentUnavailableView(
  76. String(localized: "No Glucose Data"),
  77. systemImage: "chart.bar.fill",
  78. description: Text("Glucose statistics will appear here once data is available.")
  79. )
  80. } else {
  81. timeInRangeCard
  82. glucoseStatsCard
  83. HStack {
  84. var hintText: String {
  85. switch state.selectedGlucoseChartType {
  86. case .percentile:
  87. String(localized: "Tap and hold the AGP graph or Time-in-Range ring to reveal more details.")
  88. case .distribution:
  89. String(localized: "Tap and hold the Time-in-Range ring to reveal more details.")
  90. }
  91. }
  92. Image(systemName: "hand.draw.fill")
  93. .foregroundStyle(Color.primary)
  94. .padding(.leading)
  95. Text(hintText)
  96. .foregroundStyle(Color.secondary)
  97. .padding(.trailing)
  98. }.font(.footnote)
  99. }
  100. }
  101. private var timeInRangeCard: some View {
  102. StatCard {
  103. VStack(spacing: Constants.spacing) {
  104. switch state.selectedGlucoseChartType {
  105. case .percentile:
  106. GlucosePercentileChart(
  107. glucose: state.glucoseFromPersistence,
  108. highLimit: state.highLimit,
  109. lowLimit: state.lowLimit,
  110. units: state.units,
  111. hourlyStats: state.hourlyStats,
  112. isToday: state.selectedIntervalForGlucoseStats == .today
  113. )
  114. case .distribution:
  115. GlucoseDistributionChart(
  116. glucose: state.glucoseFromPersistence,
  117. highLimit: state.highLimit,
  118. lowLimit: state.lowLimit,
  119. units: state.units,
  120. glucoseRangeStats: state.glucoseRangeStats,
  121. timeInRangeType: state.timeInRangeType
  122. )
  123. }
  124. }
  125. }
  126. }
  127. private var glucoseStatsCard: some View {
  128. StatCard {
  129. VStack(spacing: Constants.spacing) {
  130. GlucoseSectorChart(
  131. highLimit: state.highLimit,
  132. units: state.units,
  133. glucose: state.glucoseFromPersistence,
  134. timeInRangeType: state.timeInRangeType
  135. )
  136. Divider()
  137. GlucoseMetricsView(
  138. units: state.units,
  139. eA1cDisplayUnit: state.eA1cDisplayUnit,
  140. glucose: state.glucoseFromPersistence
  141. )
  142. }
  143. }
  144. }
  145. @ViewBuilder var insulinView: some View {
  146. HStack {
  147. Text("Chart Type")
  148. .font(.headline)
  149. Spacer()
  150. Picker("Insulin Chart Type", selection: $state.selectedInsulinChartType) {
  151. ForEach(StateModel.InsulinChartType.allCases, id: \.self) { type in
  152. Text(type.displayName)
  153. }
  154. }.pickerStyle(.menu)
  155. }.padding(.horizontal)
  156. Picker("Duration", selection: $state.selectedIntervalForInsulinStats) {
  157. ForEach(StateModel.StatsTimeInterval.allCases) { timeInterval in
  158. Text(timeInterval.displayName).tag(timeInterval)
  159. }
  160. }
  161. .pickerStyle(.segmented)
  162. StatCard {
  163. switch state.selectedInsulinChartType {
  164. case .totalDailyDose:
  165. if state.dailyTDDStats.isEmpty {
  166. ContentUnavailableView(
  167. String(localized: "No TDD Data"),
  168. systemImage: "chart.bar.xaxis",
  169. description: Text("Total Daily Doses will appear here once data is available.")
  170. )
  171. } else {
  172. TotalDailyDoseChart(
  173. selectedInterval: $state.selectedIntervalForInsulinStats,
  174. tddStats: state.selectedIntervalForInsulinStats == .day ?
  175. state.hourlyTDDStats : state.dailyTDDStats,
  176. state: state
  177. )
  178. }
  179. case .bolusDistribution:
  180. var hasBolusData: Bool {
  181. state.dailyBolusStats.contains { $0.manualBolus > 0 || $0.smb > 0 || $0.external > 0 }
  182. }
  183. if state.dailyBolusStats.isEmpty || !hasBolusData {
  184. ContentUnavailableView(
  185. String(localized: "No Bolus Data"),
  186. systemImage: "cross.vial",
  187. description: Text("Bolus statistics will appear here once data is available.")
  188. )
  189. } else {
  190. BolusStatsView(
  191. selectedInterval: $state.selectedIntervalForInsulinStats,
  192. bolusStats: state.selectedIntervalForInsulinStats == .day ?
  193. state.hourlyBolusStats : state.dailyBolusStats,
  194. state: state
  195. )
  196. }
  197. }
  198. }
  199. HStack {
  200. Image(systemName: "hand.draw.fill").foregroundStyle(Color.primary)
  201. VStack(alignment: .leading) {
  202. Text("Swipe the chart to scroll through time.")
  203. Text("Tap and hold a bar to reveal more details.")
  204. }.foregroundStyle(Color.secondary)
  205. }.font(.footnote)
  206. }
  207. @ViewBuilder var loopingView: some View {
  208. HStack {
  209. Text("Chart Type")
  210. .font(.headline)
  211. Spacer()
  212. Picker("Looping Chart Type", selection: $state.selectedLoopingChartType) {
  213. ForEach(StateModel.LoopingChartType.allCases, id: \.self) { type in
  214. Text(type.displayName)
  215. }
  216. }.pickerStyle(.menu)
  217. }.padding(.horizontal)
  218. Picker("Duration", selection: $state.selectedIntervalForLoopStats) {
  219. ForEach(StateModel.StatsTimeIntervalWithToday.allCases, id: \.self) { interval in
  220. Text(interval.displayName)
  221. }
  222. }
  223. .pickerStyle(.segmented)
  224. StatCard {
  225. switch state.selectedLoopingChartType {
  226. case .loopingPerformance:
  227. if state.loopStatRecords.isEmpty {
  228. ContentUnavailableView(
  229. String(localized: "No Loop Data"),
  230. systemImage: "clock.arrow.2.circlepath",
  231. description: Text("Loop statistics will appear here once data is available.")
  232. )
  233. } else {
  234. loopingChartView
  235. loopStats
  236. }
  237. case .trioUpTime:
  238. // TODO: Trio Up-Time Chart
  239. ContentUnavailableView(
  240. String(localized: "Coming soon."),
  241. systemImage: "hourglass",
  242. description: Text("Trio Up-Time Chart")
  243. )
  244. case .cgmConnectionTrace:
  245. // TODO: CGM Connection Trace Chart
  246. ContentUnavailableView(
  247. String(localized: "Coming soon."),
  248. systemImage: "hourglass",
  249. description: Text("CGM Connection Trace Chart")
  250. )
  251. }
  252. }
  253. }
  254. private var loopingChartView: some View {
  255. VStack(spacing: Constants.spacing) {
  256. LoopBarChartView(
  257. loopStatRecords: state.loopStatRecords,
  258. selectedInterval: state.selectedIntervalForLoopStats,
  259. statsData: state.loopStats
  260. )
  261. }
  262. }
  263. private var loopStats: some View {
  264. VStack(spacing: Constants.spacing) {
  265. LoopStatsView(
  266. statsData: state.loopStats
  267. )
  268. }
  269. }
  270. @ViewBuilder var mealsView: some View {
  271. HStack {
  272. Text("Chart Type")
  273. .font(.headline)
  274. Spacer()
  275. Picker("Meal Chart Type", selection: $state.selectedMealChartType) {
  276. ForEach(StateModel.MealChartType.allCases, id: \.self) { type in
  277. Text(type.displayName)
  278. }
  279. }.pickerStyle(.menu)
  280. }.padding(.horizontal)
  281. Picker("Duration", selection: $state.selectedIntervalForMealStats) {
  282. ForEach(StateModel.StatsTimeInterval.allCases, id: \.self) { timeInterval in
  283. Text(timeInterval.displayName)
  284. }
  285. }
  286. .pickerStyle(.segmented)
  287. StatCard {
  288. switch state.selectedMealChartType {
  289. case .totalMeals:
  290. var hasMealData: Bool {
  291. state.dailyMealStats.contains { $0.carbs > 0 || $0.fat > 0 || $0.protein > 0 }
  292. }
  293. if state.dailyMealStats.isEmpty || !hasMealData {
  294. ContentUnavailableView(
  295. String(localized: "No Meal Data"),
  296. systemImage: "fork.knife",
  297. description: Text("Meal statistics will appear here once data is available.")
  298. )
  299. } else {
  300. MealStatsView(
  301. selectedInterval: $state.selectedIntervalForMealStats,
  302. mealStats: state.selectedIntervalForMealStats == .day ?
  303. state.hourlyMealStats : state.dailyMealStats,
  304. state: state
  305. )
  306. }
  307. case .mealToHypoHyperDistribution:
  308. // TODO: Meal to Hypoglycemia/Hyperglycemia Distribution
  309. ContentUnavailableView(
  310. String(localized: "Coming soon."),
  311. systemImage: "hourglass",
  312. description: Text("Meal to Hypoglycemia/Hyperglycemia Distribution Chart")
  313. )
  314. }
  315. }
  316. HStack {
  317. Image(systemName: "hand.draw.fill").foregroundStyle(Color.primary)
  318. VStack(alignment: .leading) {
  319. Text("Swipe the chart to scroll through time.")
  320. Text("Tap and hold a bar to reveal more details.")
  321. }.foregroundStyle(Color.secondary)
  322. }.font(.footnote)
  323. }
  324. }
  325. }
  326. // MARK: - Supporting Views
  327. struct StatCard<Content: View>: View {
  328. let content: Content
  329. init(@ViewBuilder content: () -> Content) {
  330. self.content = content()
  331. }
  332. var body: some View {
  333. content
  334. .padding()
  335. .background(
  336. RoundedRectangle(cornerRadius: Stat.RootView.Constants.cornerRadius)
  337. .fill(Color.secondary.opacity(Stat.RootView.Constants.backgroundOpacity))
  338. )
  339. }
  340. }