StatRootView.swift 15 KB

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