StatRootView.swift 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391
  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.selectedDurationForGlucoseStats) {
  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.selectedDurationForInsulinStats) {
  134. ForEach(StateModel.StatsTimeInterval.allCases) { timeInterval in
  135. Text(timeInterval.rawValue).tag(timeInterval)
  136. }
  137. }
  138. .pickerStyle(.segmented)
  139. switch selectedInsulinChartType {
  140. case .totalDailyDose:
  141. if state.tddStats.isEmpty {
  142. ContentUnavailableView(
  143. "No TDD Data",
  144. systemImage: "chart.bar.xaxis",
  145. description: Text("Total Daily Doses will appear here once data is available.")
  146. )
  147. } else {
  148. TDDChartView(
  149. selectedDuration: state.selectedDurationForInsulinStats,
  150. tddStats: state.tddStats,
  151. calculateAverage: { start, end in
  152. state.calculateAverageTDD(from: start, to: end)
  153. }
  154. )
  155. }
  156. case .bolusDistribution:
  157. var hasBolusData: Bool {
  158. state.bolusStats.contains { $0.manualBolus > 0 || $0.smb > 0 || $0.external > 0 }
  159. }
  160. if state.bolusStats.isEmpty || !hasBolusData {
  161. ContentUnavailableView(
  162. "No Bolus Data",
  163. systemImage: "cross.vial",
  164. description: Text("Bolus statistics will appear here once data is available.")
  165. )
  166. } else {
  167. BolusStatsView(
  168. bolusStats: state.bolusStats
  169. )
  170. }
  171. }
  172. }
  173. private var timeInRangeCard: some View {
  174. StatCard {
  175. VStack(spacing: Constants.spacing) {
  176. switch selectedGlucoseChartType {
  177. case .percentile:
  178. GlucosePercentileChart(
  179. glucose: state.glucoseFromPersistence,
  180. highLimit: state.highLimit,
  181. lowLimit: state.lowLimit,
  182. units: state.units,
  183. hourlyStats: state.hourlyStats,
  184. isToday: state.selectedDurationForGlucoseStats == .Today
  185. )
  186. case .distribution:
  187. GlucoseDistributionChart(
  188. glucose: state.glucoseFromPersistence,
  189. highLimit: state.highLimit,
  190. lowLimit: state.lowLimit,
  191. units: state.units,
  192. glucoseRangeStats: state.glucoseRangeStats
  193. )
  194. }
  195. Divider()
  196. SectorChart(
  197. highLimit: state.highLimit,
  198. lowLimit: state.lowLimit,
  199. units: state.units,
  200. hbA1cDisplayUnit: state.hbA1cDisplayUnit,
  201. timeInRangeChartStyle: state.timeInRangeChartStyle,
  202. glucose: state.glucoseFromPersistence
  203. )
  204. }
  205. }
  206. }
  207. private var glucoseStatsCard: some View {
  208. StatCard {
  209. VStack(spacing: Constants.spacing) {
  210. BareStatisticsView.HbA1cView(
  211. highLimit: state.highLimit,
  212. lowLimit: state.lowLimit,
  213. units: state.units,
  214. hbA1cDisplayUnit: state.hbA1cDisplayUnit,
  215. glucose: state.glucoseFromPersistence
  216. )
  217. Divider()
  218. BareStatisticsView.BloodGlucoseView(
  219. highLimit: state.highLimit,
  220. lowLimit: state.lowLimit,
  221. units: state.units,
  222. hbA1cDisplayUnit: state.hbA1cDisplayUnit,
  223. glucose: state.glucoseFromPersistence
  224. )
  225. }
  226. }
  227. }
  228. @ViewBuilder var loopingView: some View {
  229. HStack {
  230. Text("Chart Type")
  231. .font(.headline)
  232. Spacer()
  233. Picker("Looping Chart Type", selection: $selectedLoopingChartType) {
  234. ForEach(LoopingChartType.allCases, id: \.self) { type in
  235. Text(type.rawValue)
  236. }
  237. }.pickerStyle(.menu)
  238. }.padding(.horizontal)
  239. Picker("Duration", selection: $state.selectedDurationForLoopStats) {
  240. ForEach(StateModel.Duration.allCases, id: \.self) { duration in
  241. Text(duration.rawValue)
  242. }
  243. }
  244. .pickerStyle(.segmented)
  245. switch selectedLoopingChartType {
  246. case .loopingPerformance:
  247. if state.loopStatRecords.isEmpty {
  248. ContentUnavailableView(
  249. "No Loop Data",
  250. systemImage: "clock.arrow.2.circlepath",
  251. description: Text("Loop statistics will appear here once data is available.")
  252. )
  253. } else {
  254. loopsCard
  255. loopStats
  256. }
  257. case .trioUpTime:
  258. Text("Not yet implemented")
  259. case .cgmConnectionTrace:
  260. Text("Not yet implemented")
  261. }
  262. }
  263. private var loopsCard: some View {
  264. StatCard {
  265. VStack(spacing: Constants.spacing) {
  266. LoopStatsView(
  267. loopStatRecords: state.loopStatRecords,
  268. selectedDuration: state.selectedDurationForLoopStats,
  269. groupedStats: state.groupedLoopStats
  270. )
  271. }
  272. }
  273. }
  274. private var loopStats: some View {
  275. StatCard {
  276. VStack(spacing: Constants.spacing) {
  277. BareStatisticsView.LoopsView(
  278. highLimit: state.highLimit,
  279. lowLimit: state.lowLimit,
  280. units: state.units,
  281. hbA1cDisplayUnit: state.hbA1cDisplayUnit,
  282. loopStatRecords: state.loopStatRecords
  283. )
  284. }
  285. }
  286. }
  287. @ViewBuilder var mealsView: some View {
  288. HStack {
  289. Text("Chart Type")
  290. .font(.headline)
  291. Spacer()
  292. Picker("Meal Chart Type", selection: $selectedMealChartType) {
  293. ForEach(MealChartType.allCases, id: \.self) { type in
  294. Text(type.rawValue)
  295. }
  296. }.pickerStyle(.menu)
  297. }.padding(.horizontal)
  298. Picker("Duration", selection: $state.selectedDurationForMealStats) {
  299. ForEach(StateModel.Duration.allCases, id: \.self) { duration in
  300. Text(duration.rawValue)
  301. }
  302. }
  303. .pickerStyle(.segmented)
  304. StatCard {
  305. switch selectedMealChartType {
  306. case .totalMeals:
  307. var hasMealData: Bool {
  308. state.mealStats.contains { $0.carbs > 0 || $0.fat > 0 || $0.protein > 0 }
  309. }
  310. if state.mealStats.isEmpty || !hasMealData {
  311. ContentUnavailableView(
  312. "No Meal Data",
  313. systemImage: "fork.knife",
  314. description: Text("Meal statistics will appear here once data is available.")
  315. )
  316. } else {
  317. MealStatsView(
  318. mealStats: state.mealStats,
  319. selectedDuration: state.selectedDurationForMealStats
  320. )
  321. }
  322. case .mealToHypoHyperDistribution:
  323. Text("TODO: Meal to Hypoglycemia/Hyperglycemia Distribution")
  324. }
  325. }
  326. }
  327. }
  328. }
  329. // MARK: - Supporting Views
  330. struct StatCard<Content: View>: View {
  331. let content: Content
  332. init(@ViewBuilder content: () -> Content) {
  333. self.content = content()
  334. }
  335. var body: some View {
  336. content
  337. .padding()
  338. .background(
  339. RoundedRectangle(cornerRadius: Stat.RootView.Constants.cornerRadius)
  340. .fill(Color.secondary.opacity(Stat.RootView.Constants.backgroundOpacity))
  341. )
  342. }
  343. }