StatRootView.swift 15 KB

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