StatRootView.swift 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365
  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.title).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(GlucoseChartType.allCases, id: \.self) { type in
  63. Text(type.rawValue)
  64. }
  65. }
  66. .pickerStyle(.menu)
  67. }.padding(.horizontal)
  68. Picker("Duration", selection: $state.selectedDurationForGlucoseStats) {
  69. ForEach(StateModel.StatsTimeInterval.allCases, id: \.self) { timeInterval in
  70. Text(timeInterval.rawValue)
  71. }
  72. }
  73. .pickerStyle(.segmented)
  74. if state.glucoseFromPersistence.isEmpty {
  75. ContentUnavailableView(
  76. "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. }
  84. }
  85. private var timeInRangeCard: some View {
  86. StatCard {
  87. VStack(spacing: Constants.spacing) {
  88. switch state.selectedGlucoseChartType {
  89. case .percentile:
  90. GlucosePercentileChart(
  91. selectedDuration: $state.selectedDurationForGlucoseStats,
  92. state: state,
  93. glucose: state.glucoseFromPersistence,
  94. highLimit: state.highLimit,
  95. lowLimit: state.lowLimit,
  96. units: state.units,
  97. hourlyStats: state.hourlyStats
  98. )
  99. case .distribution:
  100. GlucoseDistributionChart(
  101. selectedDuration: $state.selectedDurationForGlucoseStats,
  102. state: state,
  103. glucose: state.glucoseFromPersistence,
  104. highLimit: state.highLimit,
  105. lowLimit: state.lowLimit,
  106. units: state.units,
  107. glucoseRangeStats: state.glucoseRangeStats
  108. )
  109. }
  110. Divider()
  111. SectorChart(
  112. highLimit: state.highLimit,
  113. lowLimit: state.lowLimit,
  114. units: state.units,
  115. hbA1cDisplayUnit: state.hbA1cDisplayUnit,
  116. timeInRangeChartStyle: state.timeInRangeChartStyle,
  117. glucose: state.glucoseFromPersistence
  118. )
  119. }
  120. }
  121. }
  122. private var glucoseStatsCard: some View {
  123. StatCard {
  124. VStack(spacing: Constants.spacing) {
  125. BareStatisticsView.HbA1cView(
  126. highLimit: state.highLimit,
  127. lowLimit: state.lowLimit,
  128. units: state.units,
  129. hbA1cDisplayUnit: state.hbA1cDisplayUnit,
  130. glucose: state.glucoseFromPersistence
  131. )
  132. Divider()
  133. BareStatisticsView.BloodGlucoseView(
  134. highLimit: state.highLimit,
  135. lowLimit: state.lowLimit,
  136. units: state.units,
  137. hbA1cDisplayUnit: state.hbA1cDisplayUnit,
  138. glucose: state.glucoseFromPersistence
  139. )
  140. }
  141. }
  142. }
  143. @ViewBuilder var insulinView: some View {
  144. HStack {
  145. Text("Chart Type")
  146. .font(.headline)
  147. Spacer()
  148. Picker("Insulin Chart Type", selection: $state.selectedInsulinChartType) {
  149. ForEach(InsulinChartType.allCases, id: \.self) { type in
  150. Text(type.rawValue)
  151. }
  152. }.pickerStyle(.menu)
  153. }.padding(.horizontal)
  154. Picker("Duration", selection: $state.selectedDurationForInsulinStats) {
  155. ForEach(StateModel.StatsTimeInterval.allCases) { timeInterval in
  156. Text(timeInterval.rawValue).tag(timeInterval)
  157. }
  158. }
  159. .pickerStyle(.segmented)
  160. StatCard {
  161. switch state.selectedInsulinChartType {
  162. case .totalDailyDose:
  163. if state.tddStats.isEmpty {
  164. ContentUnavailableView(
  165. "No TDD Data",
  166. systemImage: "chart.bar.xaxis",
  167. description: Text("Total Daily Doses will appear here once data is available.")
  168. )
  169. } else {
  170. TDDChartView(
  171. selectedDuration: $state.selectedDurationForInsulinStats,
  172. tddStats: state.tddStats,
  173. calculateAverage: { start, end in
  174. await state.calculateAverageTDD(from: start, to: end)
  175. },
  176. calculateMedian: { start, end in
  177. await state.calculateMedianTDD(from: start, to: end)
  178. }
  179. )
  180. }
  181. case .bolusDistribution:
  182. // TODO: -
  183. var hasBolusData: Bool {
  184. state.dailyBolusStats.contains { $0.manualBolus > 0 || $0.smb > 0 || $0.external > 0 }
  185. }
  186. // TODO: -
  187. if state.dailyBolusStats.isEmpty || !hasBolusData {
  188. ContentUnavailableView(
  189. "No Bolus Data",
  190. systemImage: "cross.vial",
  191. description: Text("Bolus statistics will appear here once data is available.")
  192. )
  193. } else {
  194. BolusStatsView(
  195. selectedDuration: $state.selectedDurationForInsulinStats,
  196. bolusStats: state.selectedDurationForInsulinStats == .Day ?
  197. state.hourlyBolusStats : state.dailyBolusStats,
  198. state: state
  199. )
  200. }
  201. }
  202. }
  203. }
  204. @ViewBuilder var loopingView: some View {
  205. HStack {
  206. Text("Chart Type")
  207. .font(.headline)
  208. Spacer()
  209. Picker("Looping Chart Type", selection: $state.selectedLoopingChartType) {
  210. ForEach(LoopingChartType.allCases, id: \.self) { type in
  211. Text(type.rawValue)
  212. }
  213. }.pickerStyle(.menu)
  214. }.padding(.horizontal)
  215. Picker("Duration", selection: $state.selectedDurationForLoopStats) {
  216. ForEach(StateModel.Duration.allCases, id: \.self) { duration in
  217. Text(duration.rawValue)
  218. }
  219. }
  220. .pickerStyle(.segmented)
  221. switch state.selectedLoopingChartType {
  222. case .loopingPerformance:
  223. if state.loopStatRecords.isEmpty {
  224. ContentUnavailableView(
  225. "No Loop Data",
  226. systemImage: "clock.arrow.2.circlepath",
  227. description: Text("Loop statistics will appear here once data is available.")
  228. )
  229. } else {
  230. loopsCard
  231. loopStats
  232. }
  233. case .trioUpTime:
  234. Text("Not yet implemented")
  235. case .cgmConnectionTrace:
  236. Text("Not yet implemented")
  237. }
  238. }
  239. private var loopsCard: some View {
  240. StatCard {
  241. VStack(spacing: Constants.spacing) {
  242. LoopStatsView(
  243. loopStatRecords: state.loopStatRecords,
  244. selectedDuration: state.selectedDurationForLoopStats,
  245. groupedStats: state.groupedLoopStats
  246. )
  247. }
  248. }
  249. }
  250. private var loopStats: some View {
  251. StatCard {
  252. VStack(spacing: Constants.spacing) {
  253. BareStatisticsView.LoopsView(
  254. highLimit: state.highLimit,
  255. lowLimit: state.lowLimit,
  256. units: state.units,
  257. hbA1cDisplayUnit: state.hbA1cDisplayUnit,
  258. loopStatRecords: state.loopStatRecords
  259. )
  260. }
  261. }
  262. }
  263. @ViewBuilder var mealsView: some View {
  264. HStack {
  265. Text("Chart Type")
  266. .font(.headline)
  267. Spacer()
  268. Picker("Meal Chart Type", selection: $state.selectedMealChartType) {
  269. ForEach(MealChartType.allCases, id: \.self) { type in
  270. Text(type.rawValue)
  271. }
  272. }.pickerStyle(.menu)
  273. }.padding(.horizontal)
  274. Picker("Duration", selection: $state.selectedDurationForMealStats) {
  275. ForEach(StateModel.StatsTimeInterval.allCases, id: \.self) { timeInterval in
  276. Text(timeInterval.rawValue)
  277. }
  278. }
  279. .pickerStyle(.segmented)
  280. StatCard {
  281. switch state.selectedMealChartType {
  282. case .totalMeals:
  283. // TODO: -
  284. var hasMealData: Bool {
  285. state.dailyMealStats.contains { $0.carbs > 0 || $0.fat > 0 || $0.protein > 0 }
  286. }
  287. // TODO: -
  288. if state.dailyMealStats.isEmpty || !hasMealData {
  289. ContentUnavailableView(
  290. "No Meal Data",
  291. systemImage: "fork.knife",
  292. description: Text("Meal statistics will appear here once data is available.")
  293. )
  294. } else {
  295. MealStatsView(
  296. selectedDuration: $state.selectedDurationForMealStats,
  297. mealStats: state.selectedDurationForMealStats == .Day ?
  298. state.hourlyMealStats : state.dailyMealStats,
  299. state: state
  300. )
  301. }
  302. case .mealToHypoHyperDistribution:
  303. Text("TODO: Meal to Hypoglycemia/Hyperglycemia Distribution")
  304. }
  305. }
  306. }
  307. }
  308. }
  309. // MARK: - Supporting Views
  310. struct StatCard<Content: View>: View {
  311. let content: Content
  312. init(@ViewBuilder content: () -> Content) {
  313. self.content = content()
  314. }
  315. var body: some View {
  316. content
  317. .padding()
  318. .background(
  319. RoundedRectangle(cornerRadius: Stat.RootView.Constants.cornerRadius)
  320. .fill(Color.secondary.opacity(Stat.RootView.Constants.backgroundOpacity))
  321. )
  322. }
  323. }