AggregatedStatsView.swift 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464
  1. // LoopFollow
  2. // AggregatedStatsView.swift
  3. import SwiftUI
  4. import UIKit
  5. struct AggregatedStatsView: View {
  6. @ObservedObject var viewModel: AggregatedStatsViewModel
  7. @Environment(\.dismiss) var dismiss
  8. var onDismiss: (() -> Void)?
  9. @State private var showGMI: Bool
  10. @State private var showStdDev: Bool
  11. @State private var startDate: Date
  12. @State private var endDate: Date
  13. @State private var isLoadingData = false
  14. @State private var showLoadingMessage = false
  15. @State private var loadingError = false
  16. @State private var loadingTimer: Timer?
  17. @State private var timeoutTimer: Timer?
  18. init(viewModel: AggregatedStatsViewModel, onDismiss: (() -> Void)? = nil) {
  19. self.viewModel = viewModel
  20. self.onDismiss = onDismiss
  21. _showGMI = State(initialValue: Storage.shared.showGMI.value)
  22. _showStdDev = State(initialValue: Storage.shared.showStdDev.value)
  23. let calendar = dateTimeUtils.displayCalendar()
  24. let startOfToday = calendar.startOfDay(for: Date())
  25. let end = calendar.date(byAdding: .second, value: -1, to: startOfToday) ?? Date()
  26. let endDay = calendar.startOfDay(for: end)
  27. let startDay = calendar.date(byAdding: .day, value: -7, to: endDay) ?? endDay
  28. let start = calendar.startOfDay(for: startDay)
  29. _startDate = State(initialValue: start)
  30. _endDate = State(initialValue: end)
  31. }
  32. var body: some View {
  33. ScrollView {
  34. VStack(spacing: 20) {
  35. VStack(spacing: 12) {
  36. Text("Statistics")
  37. .font(.largeTitle)
  38. .fontWeight(.bold)
  39. DateRangePicker(
  40. startDate: $startDate,
  41. endDate: $endDate,
  42. availability: viewModel.dataAvailability,
  43. onDateChange: {
  44. loadingError = false
  45. isLoadingData = true
  46. viewModel.updateDateRange(start: startDate, end: endDate) {
  47. isLoadingData = false
  48. }
  49. }
  50. )
  51. .padding(.horizontal)
  52. }
  53. .padding(.top)
  54. if loadingError {
  55. VStack(spacing: 8) {
  56. Text("#WeAreNotWaitingAnymore")
  57. .font(.subheadline)
  58. .fontWeight(.medium)
  59. .foregroundColor(.orange)
  60. Text("...because the data could not be loaded")
  61. .font(.caption)
  62. .foregroundColor(.secondary)
  63. }
  64. .padding()
  65. } else if isLoadingData {
  66. VStack(spacing: 12) {
  67. ProgressView("Loading data...")
  68. if showLoadingMessage {
  69. Text("#WeAreWaitingForData")
  70. .font(.caption)
  71. .foregroundColor(.secondary)
  72. }
  73. }
  74. .padding()
  75. }
  76. StatsGridView(
  77. simpleStats: viewModel.simpleStats,
  78. showGMI: $showGMI,
  79. showStdDev: $showStdDev
  80. )
  81. .padding(.horizontal)
  82. .opacity(isLoadingData ? 0.4 : 1.0)
  83. .disabled(isLoadingData)
  84. AGPView(viewModel: viewModel.agpStats)
  85. .padding(.horizontal)
  86. .opacity(isLoadingData ? 0.4 : 1.0)
  87. TIRView(viewModel: viewModel.tirStats)
  88. .padding(.horizontal)
  89. .opacity(isLoadingData ? 0.4 : 1.0)
  90. GRIView(viewModel: viewModel.griStats)
  91. .padding(.horizontal)
  92. .opacity(isLoadingData ? 0.4 : 1.0)
  93. }
  94. .padding(.bottom)
  95. .frame(maxWidth: .infinity)
  96. }
  97. .navigationBarTitleDisplayMode(.inline)
  98. .toolbar {
  99. if let onDismiss {
  100. ToolbarItem(placement: .navigationBarLeading) {
  101. Button("Done", action: onDismiss)
  102. }
  103. }
  104. ToolbarItem(placement: .navigationBarTrailing) {
  105. Button("Refresh") {
  106. loadingError = false
  107. isLoadingData = true
  108. viewModel.updateDateRange(start: startDate, end: endDate) {
  109. isLoadingData = false
  110. }
  111. }
  112. }
  113. }
  114. .onAppear {
  115. loadingError = false
  116. isLoadingData = true
  117. viewModel.updateDateRange(start: startDate, end: endDate) {
  118. isLoadingData = false
  119. }
  120. }
  121. .onChange(of: isLoadingData) { newValue in
  122. if newValue {
  123. showLoadingMessage = false
  124. loadingError = false
  125. // Show "still waiting" message after 3 seconds
  126. loadingTimer = Timer.scheduledTimer(withTimeInterval: 3.0, repeats: false) { _ in
  127. showLoadingMessage = true
  128. }
  129. // Timeout after 30 seconds
  130. timeoutTimer = Timer.scheduledTimer(withTimeInterval: 30.0, repeats: false) { _ in
  131. if self.isLoadingData {
  132. self.isLoadingData = false
  133. self.loadingError = true
  134. self.viewModel.clearAllStats()
  135. }
  136. }
  137. } else {
  138. loadingTimer?.invalidate()
  139. loadingTimer = nil
  140. timeoutTimer?.invalidate()
  141. timeoutTimer = nil
  142. showLoadingMessage = false
  143. }
  144. }
  145. .onDisappear {
  146. loadingTimer?.invalidate()
  147. loadingTimer = nil
  148. timeoutTimer?.invalidate()
  149. timeoutTimer = nil
  150. }
  151. .onChange(of: showGMI) { newValue in
  152. Storage.shared.showGMI.value = newValue
  153. }
  154. .onChange(of: showStdDev) { newValue in
  155. Storage.shared.showStdDev.value = newValue
  156. }
  157. }
  158. }
  159. struct AggregatedStatsContentView: View {
  160. @StateObject private var viewModel: AggregatedStatsViewModel
  161. private let onDismiss: (() -> Void)?
  162. init(mainViewController: MainViewController?, onDismiss: (() -> Void)? = nil) {
  163. _viewModel = StateObject(wrappedValue: AggregatedStatsViewModel(mainViewController: mainViewController))
  164. self.onDismiss = onDismiss
  165. }
  166. var body: some View {
  167. AggregatedStatsView(viewModel: viewModel, onDismiss: onDismiss)
  168. .preferredColorScheme(Storage.shared.appearanceMode.value.colorScheme)
  169. }
  170. }
  171. struct AggregatedStatsModalView: View {
  172. @Environment(\.dismiss) private var dismiss
  173. let mainViewController: MainViewController?
  174. var body: some View {
  175. NavigationView {
  176. AggregatedStatsContentView(
  177. mainViewController: mainViewController,
  178. onDismiss: { dismiss() }
  179. )
  180. .navigationBarTitleDisplayMode(.inline)
  181. }
  182. .preferredColorScheme(Storage.shared.appearanceMode.value.colorScheme)
  183. }
  184. }
  185. struct StatCard: View {
  186. let title: String
  187. let value: String
  188. let unit: String?
  189. let color: Color
  190. var isInteractive: Bool = false
  191. var body: some View {
  192. ZStack(alignment: .topTrailing) {
  193. VStack(alignment: .leading, spacing: 8) {
  194. Text(title)
  195. .font(.caption)
  196. .foregroundColor(.secondary)
  197. HStack(alignment: .firstTextBaseline, spacing: 4) {
  198. Text(value)
  199. .font(.title2)
  200. .fontWeight(.semibold)
  201. .foregroundColor(color)
  202. if let unit = unit {
  203. Text(unit)
  204. .font(.caption)
  205. .foregroundColor(.secondary)
  206. }
  207. }
  208. }
  209. .frame(maxWidth: .infinity, alignment: .leading)
  210. .padding()
  211. if isInteractive {
  212. Image(systemName: "chevron.up.chevron.down")
  213. .font(.caption2)
  214. .foregroundColor(.secondary.opacity(0.5))
  215. .padding(8)
  216. }
  217. }
  218. .background(Color(.systemGray6))
  219. .cornerRadius(12)
  220. }
  221. }
  222. struct InsulinTotalsCard: View {
  223. let totalNegativeBasal: Double?
  224. let totalPositiveBasal: Double?
  225. let programmedBasal: Double?
  226. let actualBasal: Double?
  227. let avgBolus: Double?
  228. let totalDailyDose: Double?
  229. var body: some View {
  230. VStack(alignment: .leading, spacing: 8) {
  231. Text("Insulin Totals")
  232. .font(.caption)
  233. .foregroundColor(.secondary)
  234. VStack(spacing: 10) {
  235. metricRow(title: "Programmed Basal", value: programmedBasal, color: .indigo)
  236. metricRow(title: "Total Negative Basal", value: totalNegativeBasal, color: .red)
  237. metricRow(title: "Total Positive Basal", value: totalPositiveBasal, color: .green)
  238. metricRow(title: "Actual Basal", value: actualBasal, color: .indigo)
  239. metricRow(title: "Avg Bolus", value: avgBolus, color: .purple)
  240. metricRow(title: "Total Daily Dose", value: totalDailyDose, color: .pink)
  241. }
  242. }
  243. .frame(maxWidth: .infinity, alignment: .leading)
  244. .padding()
  245. .background(Color(.systemGray6))
  246. .cornerRadius(12)
  247. }
  248. @ViewBuilder
  249. private func metricRow(title: String, value: Double?, color: Color) -> some View {
  250. HStack(alignment: .firstTextBaseline) {
  251. Text(title)
  252. .font(.subheadline)
  253. .foregroundColor(.secondary)
  254. Spacer()
  255. Text(formatBasal(value))
  256. .font(.headline)
  257. .fontWeight(.semibold)
  258. .foregroundColor(color)
  259. + Text(" U")
  260. .font(.caption)
  261. .foregroundColor(.secondary)
  262. }
  263. }
  264. private func formatBasal(_ value: Double?) -> String {
  265. guard let value = value else { return "---" }
  266. return String(format: "%.2f", value)
  267. }
  268. }
  269. struct StatsGridView: View {
  270. @ObservedObject var simpleStats: SimpleStatsViewModel
  271. @Binding var showGMI: Bool
  272. @Binding var showStdDev: Bool
  273. @State private var showInsulinBreakdown = false
  274. private var hasInsulinData: Bool {
  275. simpleStats.totalDailyDose != nil || simpleStats.avgBolus != nil || simpleStats.actualBasal != nil || simpleStats.programmedBasal != nil
  276. }
  277. private var hasCarbData: Bool {
  278. simpleStats.avgCarbs != nil
  279. }
  280. var body: some View {
  281. VStack(spacing: 16) {
  282. LazyVGrid(columns: [GridItem(.flexible()), GridItem(.flexible())], spacing: 16) {
  283. Button(action: {
  284. showGMI.toggle()
  285. }) {
  286. StatCard(
  287. title: showGMI ? "GMI" : "eHbA1c",
  288. value: showGMI ? formatGMI(simpleStats.gmi) : formatEhbA1c(simpleStats.avgGlucose),
  289. unit: showGMI ? "%" : (Storage.shared.units.value == "mg/dL" ? "%" : "mmol/mol"),
  290. color: .blue,
  291. isInteractive: true
  292. )
  293. }
  294. .buttonStyle(PlainButtonStyle())
  295. StatCard(
  296. title: "Avg Glucose",
  297. value: formatGlucose(simpleStats.avgGlucose),
  298. unit: Storage.shared.units.value,
  299. color: Color(red: 0.20, green: 0.99, blue: 0.70)
  300. )
  301. Button(action: {
  302. showStdDev.toggle()
  303. }) {
  304. StatCard(
  305. title: showStdDev ? "Std Deviation" : "CV",
  306. value: showStdDev ? formatStdDev(simpleStats.stdDeviation) : formatCV(simpleStats.coefficientOfVariation),
  307. unit: showStdDev ? Storage.shared.units.value : "%",
  308. color: .orange,
  309. isInteractive: true
  310. )
  311. }
  312. .buttonStyle(PlainButtonStyle())
  313. if hasCarbData {
  314. StatCard(
  315. title: "Avg Carbs",
  316. value: formatCarbs(simpleStats.avgCarbs),
  317. unit: "g/day",
  318. color: .yellow
  319. )
  320. }
  321. if hasInsulinData {
  322. StatCard(
  323. title: "Programmed Basal",
  324. value: formatInsulin(simpleStats.programmedBasal),
  325. unit: "U/day",
  326. color: .indigo
  327. )
  328. .onTapGesture(count: 3) {
  329. showInsulinBreakdown = true
  330. }
  331. StatCard(
  332. title: "Avg Bolus",
  333. value: formatInsulin(simpleStats.avgBolus),
  334. unit: "U/day",
  335. color: .purple
  336. )
  337. .onTapGesture(count: 3) {
  338. showInsulinBreakdown = true
  339. }
  340. StatCard(
  341. title: "Total Daily Dose",
  342. value: formatInsulin(simpleStats.totalDailyDose),
  343. unit: "U",
  344. color: .pink
  345. )
  346. .onTapGesture(count: 3) {
  347. showInsulinBreakdown = true
  348. }
  349. }
  350. }
  351. if hasInsulinData && showInsulinBreakdown {
  352. InsulinTotalsCard(
  353. totalNegativeBasal: simpleStats.totalNegativeBasal,
  354. totalPositiveBasal: simpleStats.totalPositiveBasal,
  355. programmedBasal: simpleStats.programmedBasal,
  356. actualBasal: simpleStats.actualBasal,
  357. avgBolus: simpleStats.avgBolus,
  358. totalDailyDose: simpleStats.totalDailyDose
  359. )
  360. }
  361. }
  362. }
  363. private func formatGMI(_ value: Double?) -> String {
  364. guard let value = value else { return "---" }
  365. return String(format: "%.1f", value)
  366. }
  367. private func formatEhbA1c(_ avgGlucose: Double?) -> String {
  368. guard let avgGlucose = avgGlucose else { return "---" }
  369. let avgGlucoseMgdL: Double
  370. if Storage.shared.units.value == "mg/dL" {
  371. avgGlucoseMgdL = avgGlucose
  372. } else {
  373. avgGlucoseMgdL = avgGlucose * 18.0182
  374. }
  375. let ehba1cPercent = (avgGlucoseMgdL + 46.7) / 28.7
  376. if Storage.shared.units.value == "mg/dL" {
  377. return String(format: "%.1f", ehba1cPercent)
  378. } else {
  379. let ehba1cMmolMol = (ehba1cPercent - 2.15) * 10.929
  380. return String(format: "%.0f", ehba1cMmolMol)
  381. }
  382. }
  383. private func formatGlucose(_ value: Double?) -> String {
  384. guard let value = value else { return "---" }
  385. if Storage.shared.units.value == "mg/dL" {
  386. return String(format: "%.0f", value)
  387. } else {
  388. return String(format: "%.1f", value)
  389. }
  390. }
  391. private func formatStdDev(_ value: Double?) -> String {
  392. guard let value = value else { return "---" }
  393. if Storage.shared.units.value == "mg/dL" {
  394. return String(format: "%.0f", value)
  395. } else {
  396. return String(format: "%.1f", value)
  397. }
  398. }
  399. private func formatInsulin(_ value: Double?) -> String {
  400. guard let value = value else { return "---" }
  401. return String(format: "%.2f", value)
  402. }
  403. private func formatCarbs(_ value: Double?) -> String {
  404. guard let value = value else { return "---" }
  405. return String(format: "%.0f", value)
  406. }
  407. private func formatCV(_ value: Double?) -> String {
  408. guard let value = value else { return "---" }
  409. return String(format: "%.1f", value)
  410. }
  411. }