StatRootView.swift 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323
  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: ViewType = .statistics
  17. @State private var selectedChartType: ChartType = .percentile
  18. enum ViewType: String, CaseIterable, Identifiable {
  19. case statistics = "Time in Range"
  20. case tdd = "Total Daily Doses"
  21. case loops = "Loop Stats"
  22. case meals = "Meal Stats"
  23. var id: String { rawValue }
  24. var title: String {
  25. switch self {
  26. case .statistics: return NSLocalizedString("Time in Range", comment: "Statistics view title")
  27. case .tdd: return NSLocalizedString("Total Daily Doses", comment: "TDD view title")
  28. case .loops: return NSLocalizedString("Loop Stats", comment: "Loop stats view title")
  29. case .meals: return NSLocalizedString("Meal Stats", comment: "Meal stats view title")
  30. }
  31. }
  32. }
  33. enum ChartType: String, CaseIterable {
  34. case percentile = "Percentile"
  35. case stacked = "Distribution"
  36. }
  37. var body: some View {
  38. VStack(spacing: Constants.spacing) {
  39. segmentedPicker
  40. contentView
  41. .animation(.easeInOut, value: selectedView)
  42. }
  43. .background(appState.trioBackgroundColor(for: colorScheme))
  44. .onAppear(perform: configureView)
  45. .navigationBarTitleDisplayMode(.inline)
  46. .navigationTitle("Statistics")
  47. .toolbar {
  48. ToolbarItem(placement: .topBarLeading) {
  49. closeButton
  50. }
  51. }
  52. }
  53. // MARK: - Views
  54. private var segmentedPicker: some View {
  55. Picker("View", selection: $selectedView) {
  56. ForEach(ViewType.allCases) { viewType in
  57. Text(viewType.title).tag(viewType)
  58. }
  59. }
  60. .pickerStyle(.segmented)
  61. .padding(.horizontal)
  62. }
  63. @ViewBuilder private var contentView: some View {
  64. switch selectedView {
  65. case .statistics:
  66. statsView()
  67. case .tdd:
  68. tddView()
  69. case .loops:
  70. loopsView()
  71. case .meals:
  72. mealsView()
  73. }
  74. }
  75. private var closeButton: some View {
  76. Button(action: state.hideModal) {
  77. Text("Close")
  78. .foregroundColor(.tabBar)
  79. }
  80. }
  81. // MARK: - Stats View
  82. @ViewBuilder func statsView() -> some View {
  83. ScrollView {
  84. VStack(spacing: Constants.spacing) {
  85. if state.glucoseFromPersistence.isEmpty {
  86. ContentUnavailableView(
  87. "No Glucose Data",
  88. systemImage: "chart.bar.fill",
  89. description: Text("Glucose statistics will appear here once data is available.")
  90. )
  91. } else {
  92. timeInRangeCard
  93. glucoseStatsCard
  94. }
  95. }
  96. .padding()
  97. }
  98. }
  99. @ViewBuilder func tddView() -> some View {
  100. ScrollView {
  101. VStack(spacing: Constants.spacing) {
  102. TDDChartView(
  103. state: state,
  104. selectedDays: $state.requestedDaysTDD,
  105. selectedEndDate: $state.requestedEndDayTDD,
  106. dailyTotalDoses: $state.dailyTotalDoses,
  107. averageTDD: state.averageTDD,
  108. ytdTDD: state.ytdTDDValue
  109. )
  110. .onChange(of: state.requestedDaysTDD) {
  111. state.updateBolusStats()
  112. }
  113. .onChange(of: state.requestedEndDayTDD) {
  114. state.updateBolusStats()
  115. }
  116. BolusStatsView(
  117. bolusStats: state.bolusStats,
  118. selectedDays: $state.requestedDaysTDD,
  119. selectedEndDate: $state.requestedEndDayTDD
  120. )
  121. }
  122. .padding()
  123. }
  124. }
  125. @ViewBuilder func loopsView() -> some View {
  126. ScrollView {
  127. VStack(spacing: Constants.spacing) {
  128. if state.loopStatRecords.isEmpty {
  129. ContentUnavailableView(
  130. "No Loop Data",
  131. systemImage: "clock.arrow.2.circlepath",
  132. description: Text("Loop statistics will appear here once data is available.")
  133. )
  134. } else {
  135. loopsCard
  136. loopStats
  137. }
  138. }
  139. .padding()
  140. }
  141. }
  142. private var timeInRangeCard: some View {
  143. StatCard {
  144. VStack(spacing: Constants.spacing) {
  145. HStack {
  146. Text("Time in Range")
  147. .font(.headline)
  148. Spacer()
  149. HStack {
  150. Picker("Duration", selection: $state.selectedDuration) {
  151. ForEach(StateModel.Duration.allCases, id: \.self) { duration in
  152. Text(duration.rawValue)
  153. }
  154. }
  155. .pickerStyle(.menu)
  156. Picker("Chart Type", selection: $selectedChartType) {
  157. ForEach(ChartType.allCases, id: \.self) { type in
  158. Text(type.rawValue)
  159. }
  160. }
  161. .pickerStyle(.menu)
  162. }
  163. }
  164. if selectedChartType == .percentile {
  165. GlucoseAreaChart(
  166. glucose: state.glucoseFromPersistence,
  167. highLimit: state.highLimit,
  168. lowLimit: state.lowLimit,
  169. isTodayOrLast24h: state.selectedDuration == .Today || state.selectedDuration == .Day,
  170. units: state.units,
  171. hourlyStats: state.hourlyStats
  172. )
  173. } else {
  174. GlucoseStackedAreaChart(
  175. glucose: state.glucoseFromPersistence,
  176. highLimit: state.highLimit,
  177. lowLimit: state.lowLimit,
  178. isToday: state.selectedDuration == .Today || state.selectedDuration == .Day,
  179. units: state.units,
  180. glucoseRangeStats: state.glucoseRangeStats
  181. )
  182. }
  183. Divider()
  184. SectorChart(
  185. highLimit: state.highLimit,
  186. lowLimit: state.lowLimit,
  187. units: state.units,
  188. hbA1cDisplayUnit: state.hbA1cDisplayUnit,
  189. timeInRangeChartStyle: state.timeInRangeChartStyle,
  190. glucose: state.glucoseFromPersistence
  191. )
  192. }
  193. }
  194. }
  195. private var glucoseStatsCard: some View {
  196. StatCard {
  197. VStack(spacing: Constants.spacing) {
  198. BareStatisticsView.HbA1cView(
  199. highLimit: state.highLimit,
  200. lowLimit: state.lowLimit,
  201. units: state.units,
  202. hbA1cDisplayUnit: state.hbA1cDisplayUnit,
  203. glucose: state.glucoseFromPersistence
  204. )
  205. Divider()
  206. BareStatisticsView.BloodGlucoseView(
  207. highLimit: state.highLimit,
  208. lowLimit: state.lowLimit,
  209. units: state.units,
  210. hbA1cDisplayUnit: state.hbA1cDisplayUnit,
  211. glucose: state.glucoseFromPersistence
  212. )
  213. }
  214. }
  215. }
  216. private var loopsCard: some View {
  217. StatCard {
  218. VStack(spacing: Constants.spacing) {
  219. HStack {
  220. Text("Loops")
  221. .font(.headline)
  222. Spacer()
  223. Picker("Duration", selection: $state.selectedDurationForLoopStats) {
  224. ForEach(StateModel.Duration.allCases, id: \.self) { duration in
  225. Text(duration.rawValue)
  226. }
  227. }
  228. .pickerStyle(.menu)
  229. }
  230. LoopStatsView(
  231. loopStatRecords: state.loopStatRecords,
  232. selectedDuration: state.selectedDurationForLoopStats,
  233. groupedStats: state.groupedLoopStats
  234. )
  235. }
  236. }
  237. }
  238. private var loopStats: some View {
  239. StatCard {
  240. VStack(spacing: Constants.spacing) {
  241. BareStatisticsView.LoopsView(
  242. highLimit: state.highLimit,
  243. lowLimit: state.lowLimit,
  244. units: state.units,
  245. hbA1cDisplayUnit: state.hbA1cDisplayUnit,
  246. loopStatRecords: state.loopStatRecords
  247. )
  248. }
  249. }
  250. }
  251. @ViewBuilder func mealsView() -> some View {
  252. ScrollView {
  253. VStack(spacing: Constants.spacing) {
  254. Picker("Duration", selection: $state.selectedDurationForMealStats) {
  255. ForEach(StateModel.Duration.allCases, id: \.self) { duration in
  256. Text(duration.rawValue)
  257. }
  258. }
  259. .pickerStyle(.menu)
  260. MealStatsView(
  261. mealStats: state.mealStats,
  262. selectedDuration: state.selectedDurationForMealStats
  263. )
  264. }
  265. .padding()
  266. }
  267. }
  268. }
  269. }
  270. // MARK: - Supporting Views
  271. struct StatCard<Content: View>: View {
  272. let content: Content
  273. init(@ViewBuilder content: () -> Content) {
  274. self.content = content()
  275. }
  276. var body: some View {
  277. content
  278. .padding()
  279. .background(
  280. RoundedRectangle(cornerRadius: Stat.RootView.Constants.cornerRadius)
  281. .fill(Color.secondary.opacity(Stat.RootView.Constants.backgroundOpacity))
  282. )
  283. }
  284. }