StatRootView.swift 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402
  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: StatisticViewType = .glucose
  17. @State private var selectedGlucoseChartType: GlucoseChartType = .percentile
  18. @State private var selectedInsulinChartType: InsulinChartType = .totalDailyDose
  19. @State private var selectedLoopingChartType: LoopingChartType = .loopingPerformance
  20. @State private var selectedMealChartType: MealChartType = .totalMeals
  21. enum StatisticViewType: String, CaseIterable, Identifiable {
  22. case glucose
  23. case insulin
  24. case looping
  25. case meals
  26. var id: String { rawValue }
  27. var title: String {
  28. switch self {
  29. case .glucose: return "Glucose"
  30. case .insulin: return "Insulin"
  31. case .looping: return "Looping"
  32. case .meals: return "Meals"
  33. }
  34. }
  35. }
  36. enum GlucoseChartType: String, CaseIterable {
  37. case percentile = "Percentile"
  38. case distribution = "Distribution"
  39. }
  40. enum InsulinChartType: String, CaseIterable {
  41. case totalDailyDose = "Total Daily Dose"
  42. case bolusDistribution = "Bolus Distribution"
  43. }
  44. enum LoopingChartType: String, CaseIterable {
  45. case loopingPerformance = "Looping Performance"
  46. case cgmConnectionTrace = "CGM Connection Trace"
  47. case trioUpTime = "Trio Up-Time"
  48. }
  49. enum MealChartType: String, CaseIterable {
  50. case totalMeals = "Total Meals"
  51. case mealToHypoHyperDistribution = "Meal to Hypo/Hyper"
  52. }
  53. var body: some View {
  54. VStack {
  55. Picker("View", selection: $selectedView) {
  56. ForEach(StatisticViewType.allCases) { viewType in
  57. Text(viewType.title).tag(viewType)
  58. }
  59. }
  60. .pickerStyle(.segmented)
  61. .padding(.horizontal)
  62. ScrollView {
  63. VStack(spacing: Constants.spacing) {
  64. switch selectedView {
  65. case .glucose:
  66. glucoseView
  67. case .insulin:
  68. insulinView
  69. case .looping:
  70. loopingView
  71. case .meals:
  72. mealsView
  73. }
  74. }
  75. .padding()
  76. }
  77. .animation(.easeInOut, value: selectedView)
  78. }
  79. .background(appState.trioBackgroundColor(for: colorScheme))
  80. .onAppear(perform: configureView)
  81. .navigationBarTitleDisplayMode(.inline)
  82. .navigationTitle("Statistics")
  83. .toolbar {
  84. ToolbarItem(placement: .topBarLeading) {
  85. Button(action: state.hideModal) {
  86. Text("Close")
  87. .foregroundColor(.tabBar)
  88. }
  89. }
  90. }
  91. }
  92. // MARK: - Stats View
  93. @ViewBuilder var glucoseView: some View {
  94. HStack {
  95. Text("Chart Type")
  96. .font(.headline)
  97. Spacer()
  98. Picker("Glucose Chart Type", selection: $selectedGlucoseChartType) {
  99. ForEach(GlucoseChartType.allCases, id: \.self) { type in
  100. Text(type.rawValue)
  101. }
  102. }
  103. .pickerStyle(.menu)
  104. }.padding(.horizontal)
  105. Picker("Duration", selection: $state.selectedDuration) {
  106. ForEach(StateModel.Duration.allCases, id: \.self) { duration in
  107. Text(duration.rawValue)
  108. }
  109. }
  110. .pickerStyle(.segmented)
  111. if state.glucoseFromPersistence.isEmpty {
  112. ContentUnavailableView(
  113. "No Glucose Data",
  114. systemImage: "chart.bar.fill",
  115. description: Text("Glucose statistics will appear here once data is available.")
  116. )
  117. } else {
  118. timeInRangeCard
  119. glucoseStatsCard
  120. }
  121. }
  122. @ViewBuilder var insulinView: some View {
  123. HStack {
  124. Text("Chart Type")
  125. .font(.headline)
  126. Spacer()
  127. Picker("Insulin Chart Type", selection: $selectedInsulinChartType) {
  128. ForEach(InsulinChartType.allCases, id: \.self) { type in
  129. Text(type.rawValue)
  130. }
  131. }.pickerStyle(.menu)
  132. }.padding(.horizontal)
  133. Picker("Duration", selection: $state.selectedDuration) {
  134. ForEach(StateModel.Duration.allCases, id: \.self) { duration in
  135. Text(duration.rawValue)
  136. }
  137. }
  138. .pickerStyle(.segmented)
  139. // TODO: rework TDDChartView and BolusView to respect selectedDays from here and omit datepicker
  140. switch selectedInsulinChartType {
  141. case .totalDailyDose:
  142. if state.dailyTotalDoses.isEmpty || state.currentTDD == 0 {
  143. ContentUnavailableView(
  144. "No TDD Data",
  145. systemImage: "chart.bar.xaxis",
  146. description: Text("Total Daily Doses will appear here once data is available.")
  147. )
  148. } else {
  149. TDDChartView(
  150. state: state,
  151. selectedDays: $state.requestedDaysTDD,
  152. selectedEndDate: $state.requestedEndDayTDD,
  153. dailyTotalDoses: $state.dailyTotalDoses,
  154. averageTDD: state.averageTDD,
  155. ytdTDD: state.ytdTDDValue
  156. )
  157. .onChange(of: state.requestedDaysTDD) {
  158. state.updateBolusStats()
  159. }
  160. .onChange(of: state.requestedEndDayTDD) {
  161. state.updateBolusStats()
  162. }
  163. }
  164. case .bolusDistribution:
  165. var hasBolusData: Bool {
  166. state.bolusStats.contains { $0.manualBolus > 0 || $0.smb > 0 || $0.external > 0 }
  167. }
  168. if state.bolusStats.isEmpty || !hasBolusData {
  169. ContentUnavailableView(
  170. "No Bolus Data",
  171. systemImage: "cross.vial",
  172. description: Text("Bolus statistics will appear here once data is available.")
  173. )
  174. } else {
  175. BolusStatsView(
  176. bolusStats: state.bolusStats,
  177. selectedDays: $state.requestedDaysTDD,
  178. selectedEndDate: $state.requestedEndDayTDD
  179. )
  180. }
  181. }
  182. }
  183. private var timeInRangeCard: some View {
  184. StatCard {
  185. VStack(spacing: Constants.spacing) {
  186. switch selectedGlucoseChartType {
  187. case .percentile:
  188. GlucosePercentileChart(
  189. glucose: state.glucoseFromPersistence,
  190. highLimit: state.highLimit,
  191. lowLimit: state.lowLimit,
  192. isTodayOrLast24h: state.selectedDuration == .Today || state.selectedDuration == .Day,
  193. units: state.units,
  194. hourlyStats: state.hourlyStats
  195. )
  196. case .distribution:
  197. GlucoseDistributionChart(
  198. glucose: state.glucoseFromPersistence,
  199. highLimit: state.highLimit,
  200. lowLimit: state.lowLimit,
  201. isToday: state.selectedDuration == .Today || state.selectedDuration == .Day,
  202. units: state.units,
  203. glucoseRangeStats: state.glucoseRangeStats
  204. )
  205. }
  206. Divider()
  207. SectorChart(
  208. highLimit: state.highLimit,
  209. lowLimit: state.lowLimit,
  210. units: state.units,
  211. hbA1cDisplayUnit: state.hbA1cDisplayUnit,
  212. timeInRangeChartStyle: state.timeInRangeChartStyle,
  213. glucose: state.glucoseFromPersistence
  214. )
  215. }
  216. }
  217. }
  218. private var glucoseStatsCard: some View {
  219. StatCard {
  220. VStack(spacing: Constants.spacing) {
  221. BareStatisticsView.HbA1cView(
  222. highLimit: state.highLimit,
  223. lowLimit: state.lowLimit,
  224. units: state.units,
  225. hbA1cDisplayUnit: state.hbA1cDisplayUnit,
  226. glucose: state.glucoseFromPersistence
  227. )
  228. Divider()
  229. BareStatisticsView.BloodGlucoseView(
  230. highLimit: state.highLimit,
  231. lowLimit: state.lowLimit,
  232. units: state.units,
  233. hbA1cDisplayUnit: state.hbA1cDisplayUnit,
  234. glucose: state.glucoseFromPersistence
  235. )
  236. }
  237. }
  238. }
  239. @ViewBuilder var loopingView: some View {
  240. HStack {
  241. Text("Chart Type")
  242. .font(.headline)
  243. Spacer()
  244. Picker("Looping Chart Type", selection: $selectedLoopingChartType) {
  245. ForEach(LoopingChartType.allCases, id: \.self) { type in
  246. Text(type.rawValue)
  247. }
  248. }.pickerStyle(.menu)
  249. }.padding(.horizontal)
  250. Picker("Duration", selection: $state.selectedDurationForLoopStats) {
  251. ForEach(StateModel.Duration.allCases, id: \.self) { duration in
  252. Text(duration.rawValue)
  253. }
  254. }
  255. .pickerStyle(.segmented)
  256. switch selectedLoopingChartType {
  257. case .loopingPerformance:
  258. if state.loopStatRecords.isEmpty {
  259. ContentUnavailableView(
  260. "No Loop Data",
  261. systemImage: "clock.arrow.2.circlepath",
  262. description: Text("Loop statistics will appear here once data is available.")
  263. )
  264. } else {
  265. loopsCard
  266. loopStats
  267. }
  268. case .trioUpTime:
  269. Text("Not yet implemented")
  270. case .cgmConnectionTrace:
  271. Text("Not yet implemented")
  272. }
  273. }
  274. private var loopsCard: some View {
  275. StatCard {
  276. VStack(spacing: Constants.spacing) {
  277. LoopStatsView(
  278. loopStatRecords: state.loopStatRecords,
  279. selectedDuration: state.selectedDurationForLoopStats,
  280. groupedStats: state.groupedLoopStats
  281. )
  282. }
  283. }
  284. }
  285. private var loopStats: some View {
  286. StatCard {
  287. VStack(spacing: Constants.spacing) {
  288. BareStatisticsView.LoopsView(
  289. highLimit: state.highLimit,
  290. lowLimit: state.lowLimit,
  291. units: state.units,
  292. hbA1cDisplayUnit: state.hbA1cDisplayUnit,
  293. loopStatRecords: state.loopStatRecords
  294. )
  295. }
  296. }
  297. }
  298. @ViewBuilder var mealsView: some View {
  299. HStack {
  300. Text("Chart Type")
  301. .font(.headline)
  302. Spacer()
  303. Picker("Meal Chart Type", selection: $selectedMealChartType) {
  304. ForEach(MealChartType.allCases, id: \.self) { type in
  305. Text(type.rawValue)
  306. }
  307. }.pickerStyle(.menu)
  308. }.padding(.horizontal)
  309. Picker("Duration", selection: $state.selectedDurationForMealStats) {
  310. ForEach(StateModel.Duration.allCases, id: \.self) { duration in
  311. Text(duration.rawValue)
  312. }
  313. }
  314. .pickerStyle(.segmented)
  315. StatCard {
  316. switch selectedMealChartType {
  317. case .totalMeals:
  318. var hasMealData: Bool {
  319. state.mealStats.contains { $0.carbs > 0 || $0.fat > 0 || $0.protein > 0 }
  320. }
  321. if state.mealStats.isEmpty || !hasMealData {
  322. ContentUnavailableView(
  323. "No Meal Data",
  324. systemImage: "fork.knife",
  325. description: Text("Meal statistics will appear here once data is available.")
  326. )
  327. } else {
  328. MealStatsView(
  329. mealStats: state.mealStats,
  330. selectedDuration: state.selectedDurationForMealStats
  331. )
  332. }
  333. case .mealToHypoHyperDistribution:
  334. Text("TODO: Meal to Hypoglycemia/Hyperglycemia Distribution")
  335. }
  336. }
  337. }
  338. }
  339. }
  340. // MARK: - Supporting Views
  341. struct StatCard<Content: View>: View {
  342. let content: Content
  343. init(@ViewBuilder content: () -> Content) {
  344. self.content = content()
  345. }
  346. var body: some View {
  347. content
  348. .padding()
  349. .background(
  350. RoundedRectangle(cornerRadius: Stat.RootView.Constants.cornerRadius)
  351. .fill(Color.secondary.opacity(Stat.RootView.Constants.backgroundOpacity))
  352. )
  353. }
  354. }