StatRootView.swift 18 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443
  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. @State private var isGlucoseDaySelected: Bool = false
  18. private var intervalOptions: [Stat.StateModel.StatsTimeIntervalWithToday] {
  19. state.selectedGlucoseChartType == .percentileByDay || state.selectedGlucoseChartType == .distributionByDay
  20. ? [.week, .month, .total] : Stat.StateModel.StatsTimeIntervalWithToday.allCases
  21. }
  22. var body: some View {
  23. VStack {
  24. Picker("View", selection: $selectedView) {
  25. ForEach(StateModel.StatisticViewType.allCases) { viewType in
  26. Text(viewType.displayName).tag(viewType)
  27. }
  28. }
  29. .pickerStyle(.segmented)
  30. .padding(.horizontal)
  31. ScrollView {
  32. VStack(spacing: Constants.spacing) {
  33. switch selectedView {
  34. case .glucose:
  35. glucoseView
  36. case .insulin:
  37. insulinView
  38. case .looping:
  39. loopingView
  40. case .meals:
  41. mealsView
  42. }
  43. }
  44. .padding()
  45. }
  46. }
  47. .background(appState.trioBackgroundColor(for: colorScheme))
  48. .onAppear(perform: configureView)
  49. .navigationBarTitleDisplayMode(.inline)
  50. .navigationTitle("Statistics")
  51. .toolbar {
  52. ToolbarItem(placement: .topBarLeading) {
  53. Button(action: state.hideModal) {
  54. Text("Close")
  55. .foregroundColor(.tabBar)
  56. }
  57. }
  58. }
  59. }
  60. // MARK: - Stats View
  61. @ViewBuilder var glucoseView: some View {
  62. HStack {
  63. Text("Chart Type")
  64. .font(.headline)
  65. Spacer()
  66. Picker("Glucose Chart Type", selection: $state.selectedGlucoseChartType) {
  67. ForEach(StateModel.GlucoseChartType.allCases, id: \.self) { type in
  68. Text(type.displayName)
  69. }
  70. }
  71. .pickerStyle(.menu)
  72. .onChange(of: state.selectedGlucoseChartType) { _, newValue in
  73. // If switching to daily chart and day/today is selected, switch to week
  74. if newValue == .percentileByDay || newValue == .distributionByDay,
  75. state.selectedIntervalForGlucoseStats == .day || state.selectedIntervalForGlucoseStats == .today
  76. {
  77. state.selectedIntervalForGlucoseStats = .week
  78. }
  79. }
  80. }.padding(.horizontal)
  81. Picker("Duration", selection: $state.selectedIntervalForGlucoseStats) {
  82. ForEach(intervalOptions, id: \.self) { timeInterval in
  83. Text(timeInterval.displayName)
  84. }
  85. }
  86. .pickerStyle(.segmented)
  87. if state.glucoseFromPersistence.isEmpty {
  88. ContentUnavailableView(
  89. String(localized: "No Glucose Data"),
  90. systemImage: "chart.bar.fill",
  91. description: Text("Glucose statistics will appear here once data is available.")
  92. )
  93. } else {
  94. timeInRangeCard
  95. if !isGlucoseDaySelected && state.selectedGlucoseChartType != .percentileByDay && state
  96. .selectedGlucoseChartType != .distributionByDay
  97. {
  98. glucoseStatsCard
  99. }
  100. HStack {
  101. var hintText: String {
  102. switch state.selectedGlucoseChartType {
  103. case .percentileByTime:
  104. String(localized: "Tap and hold the AGP graph or Time-in-Range ring to reveal more details.")
  105. case .distributionByTime:
  106. String(localized: "Tap and hold the Time-in-Range ring to reveal more details.")
  107. case .percentileByDay:
  108. String(
  109. localized: "Tap a percentile or tap and hold a bar to reveal more details. Swipe to scroll through time."
  110. )
  111. case .distributionByDay:
  112. String(
  113. localized: "Tap and hold a bar in the chart to reveal more details. Swipe to scroll through time."
  114. )
  115. }
  116. }
  117. Image(systemName: "hand.draw.fill")
  118. .foregroundStyle(Color.primary)
  119. .padding(.leading)
  120. Text(hintText)
  121. .foregroundStyle(Color.secondary)
  122. .padding(.trailing)
  123. }.font(.footnote)
  124. }
  125. }
  126. private var timeInRangeCard: some View {
  127. StatCard {
  128. VStack(spacing: Constants.spacing) {
  129. switch state.selectedGlucoseChartType {
  130. case .distributionByDay,
  131. .percentileByDay:
  132. let interval: Stat.StateModel.StatsTimeInterval = {
  133. switch state.selectedIntervalForGlucoseStats {
  134. case .month,
  135. .total:
  136. return Stat.StateModel.StatsTimeInterval(
  137. rawValue: state.selectedIntervalForGlucoseStats.rawValue
  138. )!
  139. default:
  140. return .week
  141. }
  142. }()
  143. if state.selectedGlucoseChartType == .percentileByDay {
  144. GlucoseDailyPercentileChart(
  145. glucose: state.glucoseFromPersistence,
  146. highLimit: state.highLimit,
  147. units: state.units,
  148. timeInRangeType: state.timeInRangeType,
  149. selectedInterval: interval,
  150. isDaySelected: $isGlucoseDaySelected,
  151. state: state
  152. )
  153. } else { // if state.selectedGlucoseChartType == .distributionByDay
  154. GlucoseDailyDistributionChart(
  155. glucose: state.glucoseReadings,
  156. highLimit: state.highLimit,
  157. units: state.units,
  158. timeInRangeType: state.timeInRangeType,
  159. selectedInterval: interval,
  160. eA1cDisplayUnit: state.eA1cDisplayUnit,
  161. isDaySelected: $isGlucoseDaySelected,
  162. state: state
  163. )
  164. }
  165. case .percentileByTime:
  166. GlucosePercentileChart(
  167. glucose: state.glucoseFromPersistence,
  168. highLimit: state.highLimit,
  169. timeInRangeType: state.timeInRangeType,
  170. units: state.units,
  171. hourlyStats: state.hourlyStats,
  172. isToday: state.selectedIntervalForGlucoseStats == .today
  173. )
  174. case .distributionByTime:
  175. GlucoseDistributionChart(
  176. glucose: state.glucoseReadings,
  177. highLimit: state.highLimit,
  178. lowLimit: state.lowLimit,
  179. units: state.units,
  180. glucoseRangeStats: state.glucoseRangeStats,
  181. timeInRangeType: state.timeInRangeType
  182. )
  183. }
  184. }
  185. }
  186. }
  187. private var glucoseStatsCard: some View {
  188. StatCard {
  189. VStack(spacing: Constants.spacing) {
  190. GlucoseSectorChart(
  191. highLimit: state.highLimit,
  192. units: state.units,
  193. glucose: state.glucoseFromPersistence,
  194. timeInRangeType: state.timeInRangeType,
  195. showChart: true
  196. )
  197. Divider()
  198. GlucoseMetricsView(
  199. units: state.units,
  200. eA1cDisplayUnit: state.eA1cDisplayUnit,
  201. glucose: state.glucoseFromPersistence
  202. )
  203. }
  204. }
  205. }
  206. @ViewBuilder var insulinView: some View {
  207. HStack {
  208. Text("Chart Type")
  209. .font(.headline)
  210. Spacer()
  211. Picker("Insulin Chart Type", selection: $state.selectedInsulinChartType) {
  212. ForEach(StateModel.InsulinChartType.allCases, id: \.self) { type in
  213. Text(type.displayName)
  214. }
  215. }.pickerStyle(.menu)
  216. }.padding(.horizontal)
  217. Picker("Duration", selection: $state.selectedIntervalForInsulinStats) {
  218. ForEach(StateModel.StatsTimeInterval.allCases) { timeInterval in
  219. Text(timeInterval.displayName).tag(timeInterval)
  220. }
  221. }
  222. .pickerStyle(.segmented)
  223. StatCard {
  224. switch state.selectedInsulinChartType {
  225. case .totalDailyDose:
  226. if state.dailyTDDStats.isEmpty {
  227. ContentUnavailableView(
  228. String(localized: "No TDD Data"),
  229. systemImage: "chart.bar.xaxis",
  230. description: Text("Total Daily Doses will appear here once data is available.")
  231. )
  232. } else {
  233. TotalDailyDoseChart(
  234. selectedInterval: $state.selectedIntervalForInsulinStats,
  235. tddStats: state.selectedIntervalForInsulinStats == .day ?
  236. state.hourlyTDDStats : state.dailyTDDStats,
  237. state: state
  238. )
  239. }
  240. case .bolusDistribution:
  241. var hasBolusData: Bool {
  242. state.dailyBolusStats.contains { $0.manualBolus > 0 || $0.smb > 0 || $0.external > 0 }
  243. }
  244. if state.dailyBolusStats.isEmpty || !hasBolusData {
  245. ContentUnavailableView(
  246. String(localized: "No Bolus Data"),
  247. systemImage: "cross.vial",
  248. description: Text("Bolus statistics will appear here once data is available.")
  249. )
  250. } else {
  251. BolusStatsView(
  252. selectedInterval: $state.selectedIntervalForInsulinStats,
  253. bolusStats: state.selectedIntervalForInsulinStats == .day ?
  254. state.hourlyBolusStats : state.dailyBolusStats,
  255. state: state
  256. )
  257. }
  258. }
  259. }
  260. HStack {
  261. Image(systemName: "hand.draw.fill").foregroundStyle(Color.primary)
  262. VStack(alignment: .leading) {
  263. Text("Swipe the chart to scroll through time.")
  264. Text("Tap and hold a bar to reveal more details.")
  265. }.foregroundStyle(Color.secondary)
  266. }.font(.footnote)
  267. }
  268. @ViewBuilder var loopingView: some View {
  269. HStack {
  270. Text("Chart Type")
  271. .font(.headline)
  272. Spacer()
  273. Picker("Looping Chart Type", selection: $state.selectedLoopingChartType) {
  274. ForEach(StateModel.LoopingChartType.allCases, id: \.self) { type in
  275. Text(type.displayName)
  276. }
  277. }.pickerStyle(.menu)
  278. }.padding(.horizontal)
  279. Picker("Duration", selection: $state.selectedIntervalForLoopStats) {
  280. ForEach(StateModel.StatsTimeIntervalWithToday.allCases, id: \.self) { interval in
  281. Text(interval.displayName)
  282. }
  283. }
  284. .pickerStyle(.segmented)
  285. StatCard {
  286. switch state.selectedLoopingChartType {
  287. case .loopingPerformance:
  288. if state.loopStatRecords.isEmpty {
  289. ContentUnavailableView(
  290. String(localized: "No Loop Data"),
  291. systemImage: "clock.arrow.2.circlepath",
  292. description: Text("Loop statistics will appear here once data is available.")
  293. )
  294. } else {
  295. loopingChartView
  296. loopStats
  297. }
  298. case .cgmConnectionTrace,
  299. .trioUpTime:
  300. // TODO: Trio Up-Time Chart & CGM Connection Trace Chart
  301. ContentUnavailableView(
  302. String(localized: "Coming soon."),
  303. systemImage: "hourglass",
  304. description: Text(state.selectedLoopingChartType.displayName)
  305. )
  306. }
  307. }
  308. }
  309. private var loopingChartView: some View {
  310. VStack(spacing: Constants.spacing) {
  311. LoopBarChartView(
  312. loopStatRecords: state.loopStatRecords,
  313. selectedInterval: state.selectedIntervalForLoopStats,
  314. statsData: state.loopStats
  315. )
  316. }
  317. }
  318. private var loopStats: some View {
  319. VStack(spacing: Constants.spacing) {
  320. LoopStatsView(
  321. statsData: state.loopStats
  322. )
  323. }
  324. }
  325. @ViewBuilder var mealsView: some View {
  326. HStack {
  327. Text("Chart Type")
  328. .font(.headline)
  329. Spacer()
  330. Picker("Meal Chart Type", selection: $state.selectedMealChartType) {
  331. ForEach(StateModel.MealChartType.allCases, id: \.self) { type in
  332. Text(type.displayName)
  333. }
  334. }.pickerStyle(.menu)
  335. }.padding(.horizontal)
  336. Picker("Duration", selection: $state.selectedIntervalForMealStats) {
  337. ForEach(StateModel.StatsTimeInterval.allCases, id: \.self) { timeInterval in
  338. Text(timeInterval.displayName)
  339. }
  340. }
  341. .pickerStyle(.segmented)
  342. StatCard {
  343. switch state.selectedMealChartType {
  344. case .totalMeals:
  345. var hasMealData: Bool {
  346. state.dailyMealStats.contains { $0.carbs > 0 || $0.fat > 0 || $0.protein > 0 }
  347. }
  348. if state.dailyMealStats.isEmpty || !hasMealData {
  349. ContentUnavailableView(
  350. String(localized: "No Meal Data"),
  351. systemImage: "fork.knife",
  352. description: Text("Meal statistics will appear here once data is available.")
  353. )
  354. } else {
  355. MealStatsView(
  356. selectedInterval: $state.selectedIntervalForMealStats,
  357. mealStats: state.selectedIntervalForMealStats == .day ?
  358. state.hourlyMealStats : state.dailyMealStats,
  359. state: state
  360. )
  361. }
  362. case .mealToHypoHyperDistribution:
  363. // TODO: Meal to Hypoglycemia/Hyperglycemia Distribution
  364. ContentUnavailableView(
  365. String(localized: "Coming soon."),
  366. systemImage: "hourglass",
  367. description: Text(state.selectedMealChartType.displayName)
  368. )
  369. }
  370. }
  371. HStack {
  372. Image(systemName: "hand.draw.fill").foregroundStyle(Color.primary)
  373. VStack(alignment: .leading) {
  374. Text("Swipe the chart to scroll through time.")
  375. Text("Tap and hold a bar to reveal more details.")
  376. }.foregroundStyle(Color.secondary)
  377. }.font(.footnote)
  378. }
  379. }
  380. }
  381. // MARK: - Supporting Views
  382. struct StatCard<Content: View>: View {
  383. let content: Content
  384. init(@ViewBuilder content: () -> Content) {
  385. self.content = content()
  386. }
  387. var body: some View {
  388. content
  389. .padding()
  390. .background(
  391. RoundedRectangle(cornerRadius: Stat.RootView.Constants.cornerRadius)
  392. .fill(Color.secondary.opacity(Stat.RootView.Constants.backgroundOpacity))
  393. )
  394. }
  395. }