| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344 |
- import Charts
- import SwiftUI
- /// A view that displays a bar chart for Total Daily Dose (TDD) statistics.
- ///
- /// This view presents insulin usage over time, with the ability to adjust the time interval
- /// and scroll through historical data.
- struct TotalDailyDoseChart: View {
- /// The selected time interval for displaying statistics.
- @Binding var selectedInterval: Stat.StateModel.StatsTimeInterval
- /// The list of TDD statistics data.
- let tddStats: [TDDStats]
- /// The state model containing cached statistics data.
- let state: Stat.StateModel
- /// The current scroll position in the chart.
- @State private var scrollPosition = Date()
- /// The currently selected date in the chart.
- @State private var selectedDate: Date?
- /// The calculated average TDD for the visible range.
- @State private var currentAverage: Double = 0
- /// Timer to throttle updates when scrolling.
- @State private var updateTimer = Stat.UpdateTimer()
- /// Sum of hourly doses for `Day` view
- @State private var sumOfHourlyDoses: Double = 0
- /// The actual chart plot's width in pixel
- @State private var chartWidth: CGFloat = 0
- /// Computes the visible date range based on the current scroll position.
- private var visibleDateRange: (start: Date, end: Date) {
- StatChartUtils.visibleDateRange(from: scrollPosition, for: selectedInterval)
- }
- /// Retrieves the TDD statistic for a given date.
- /// - Parameter date: The date for which to retrieve TDD data.
- /// - Returns: The `TDDStats` object if available, otherwise `nil`.
- private func getTDDForDate(_ date: Date) -> TDDStats? {
- tddStats.first { stat in
- StatChartUtils.isSameTimeUnit(stat.date, date, for: selectedInterval)
- }
- }
- /// Updates the average TDD value based on the visible date range.
- private func updateAverages() {
- currentAverage = state.getCachedTDDAverages(for: visibleDateRange)
- }
- /// Updates the total of hourly doses for `Day` view
- private func updateTotalDoses() {
- sumOfHourlyDoses = tddStats.filter({ $0.date >= visibleDateRange.start && $0.date <= visibleDateRange.end })
- .reduce(0, { result, stat in
- result + stat.amount
- })
- }
- var body: some View {
- VStack(alignment: .leading, spacing: 8) {
- statsView.padding(.bottom)
- VStack(alignment: .trailing) {
- Text("Total Daily Dose (U)")
- .foregroundStyle(.secondary)
- .font(.footnote)
- .padding(.bottom, 4)
- chartsView
- .background(
- GeometryReader { geo in
- Color.clear
- .onAppear { chartWidth = geo.size.width }
- .onChange(of: geo.size.width) { _, newValue in chartWidth = newValue }
- }
- )
- }
- }
- .onAppear {
- scrollPosition = StatChartUtils.getInitialScrollPosition(for: selectedInterval)
- updateAverages()
- updateTotalDoses()
- }
- .onChange(of: scrollPosition) {
- updateTimer.scheduleUpdate {
- updateAverages()
- if selectedInterval == .day {
- updateTotalDoses()
- }
- }
- }
- .onChange(of: selectedInterval) {
- Task {
- scrollPosition = StatChartUtils.getInitialScrollPosition(for: selectedInterval)
- updateAverages()
- if selectedInterval == .day {
- updateTotalDoses()
- }
- }
- }
- }
- /// A view displaying the statistics summary including average TDD.
- private var statsView: some View {
- HStack {
- if selectedInterval == .day {
- Grid(alignment: .leading) {
- GridRow {
- Text("Average:")
- Text(currentAverage.formatted(.number.precision(.fractionLength(1))))
- + Text("\u{00A0}") + Text("U")
- }
- GridRow {
- Text("Total:")
- Text(sumOfHourlyDoses.formatted(.number.precision(.fractionLength(1))))
- + Text("\u{00A0}") + Text("U")
- }
- }
- .font(.headline)
- } else {
- Group {
- Text("Average:")
- Text(currentAverage.formatted(.number.precision(.fractionLength(1))))
- + Text("\u{00A0}") + Text("U")
- }
- .font(.headline)
- }
- Spacer()
- Text(
- StatChartUtils
- .formatVisibleDateRange(from: visibleDateRange.start, to: visibleDateRange.end, for: selectedInterval)
- )
- .font(.callout)
- .foregroundStyle(.secondary)
- }
- }
- /// A view displaying the bar chart for TDD statistics.
- private var chartsView: some View {
- Chart {
- ForEach(tddStats) { stat in
- BarMark(
- x: .value("Date", stat.date, unit: selectedInterval == .day ? .hour : .day),
- y: .value("Amount", stat.amount)
- )
- .foregroundStyle(Color.insulin)
- .opacity(
- selectedDate.map { date in
- StatChartUtils.isSameTimeUnit(stat.date, date, for: selectedInterval) ? 1 : 0.3
- } ?? 1
- )
- }
- // Selection popover outside of the ForEach loop!
- if let selectedDate,
- let selectedTDD = getTDDForDate(selectedDate)
- {
- RuleMark(
- x: .value("Selected Date", selectedDate)
- )
- .foregroundStyle(Color.insulin.opacity(0.5))
- .annotation(
- position: .top,
- spacing: 0,
- overflowResolution: .init(x: .fit(to: .chart), y: .fit(to: .chart))
- ) {
- TDDSelectionPopover(
- selectedDate: selectedDate,
- tdd: selectedTDD,
- selectedInterval: selectedInterval,
- domain: visibleDateRange,
- chartWidth: chartWidth
- )
- }
- }
- // Dummy PointMark to force SwiftCharts to render a visible domain of 00:00-23:59
- // i.e. single day from midnight to midnight
- if selectedInterval == .day {
- let calendar = Calendar.current
- let midnight = calendar.startOfDay(for: Date())
- let nextMidnight = calendar.date(byAdding: .day, value: 1, to: midnight)!
- PointMark(
- x: .value("Time", nextMidnight),
- y: .value("Dummy", 0)
- )
- .opacity(0) // ensures dummy ChartContent is hidden
- }
- }
- .chartYAxis {
- AxisMarks(position: .trailing) { value in
- if let amount = value.as(Double.self) {
- AxisValueLabel {
- Text(amount.formatted(.number.precision(.fractionLength(0))))
- .font(.footnote)
- }
- AxisGridLine()
- }
- }
- }
- .chartXAxis {
- AxisMarks(preset: .aligned, values: .stride(by: selectedInterval == .day ? .hour : .day)) { value in
- if let date = value.as(Date.self) {
- let day = Calendar.current.component(.day, from: date)
- let hour = Calendar.current.component(.hour, from: date)
- switch selectedInterval {
- case .day:
- if hour % 6 == 0 { // Show only every 6 hours
- AxisValueLabel(format: StatChartUtils.dateFormat(for: selectedInterval), centered: true)
- .font(.footnote)
- AxisGridLine()
- }
- case .month:
- let weekday = calendar.component(.weekday, from: date)
- if weekday == calendar.firstWeekday { // Only show the first day of the week
- AxisValueLabel(format: StatChartUtils.dateFormat(for: selectedInterval), centered: true)
- .font(.footnote)
- AxisGridLine()
- }
- case .total:
- // Only show every other month
- if day == 1 && Calendar.current.component(.month, from: date) % 2 == 1 {
- AxisValueLabel(format: StatChartUtils.dateFormat(for: selectedInterval), centered: true)
- .font(.footnote)
- AxisGridLine()
- }
- default:
- AxisValueLabel(format: StatChartUtils.dateFormat(for: selectedInterval), centered: true)
- .font(.footnote)
- AxisGridLine()
- }
- }
- }
- }
- .chartScrollableAxes(.horizontal)
- .chartXSelection(value: $selectedDate.animation(.easeInOut))
- .chartScrollPosition(x: $scrollPosition)
- .chartScrollTargetBehavior(
- .valueAligned(
- matching: selectedInterval == .day ?
- DateComponents(minute: 0) :
- DateComponents(hour: 0),
- majorAlignment: .matching(StatChartUtils.alignmentComponents(for: selectedInterval))
- )
- )
- .chartXVisibleDomain(length: StatChartUtils.visibleDomainLength(for: selectedInterval))
- .frame(height: 250)
- }
- }
- /// A popover view displaying TDD (Total Daily Dose) for a given time period.
- /// Shows the insulin amount in units (U) for an hourly or daily interval, depending on `selectedInterval`.
- ///
- /// - Parameters:
- /// - date: The reference date for determining the displayed time range.
- /// - tdd: The TDDStats containing insulin usage data.
- /// - selectedInterval: The selected time interval (hourly or daily).
- private struct TDDSelectionPopover: View {
- let selectedDate: Date
- let tdd: TDDStats
- let selectedInterval: Stat.StateModel.StatsTimeInterval
- let domain: (start: Date, end: Date)
- let chartWidth: CGFloat
- @State private var popoverSize: CGSize = .zero
- @Environment(\.colorScheme) var colorScheme
- private var timeText: String {
- if selectedInterval == .day {
- let hour = Calendar.current.component(.hour, from: selectedDate)
- return selectedDate.formatted(.dateTime.month().day().weekday()) + "\n" + "\(hour):00-\(hour + 1):00"
- } else {
- return selectedDate.formatted(.dateTime.month().day().weekday())
- }
- }
- private func xOffset() -> CGFloat {
- let domainDuration = domain.end.timeIntervalSince(domain.start)
- guard domainDuration > 0, chartWidth > 0 else { return 0 }
- let popoverWidth = popoverSize.width
- // Convert dates to pixel'd x-condition
- let dateFraction = selectedDate.timeIntervalSince(domain.start) / domainDuration
- let x_selected = dateFraction * chartWidth
- // TODO: this is semi hacky, can this be improved?
- let x_left = x_selected - (popoverWidth / 2) // Left edge of popover
- let x_right = x_selected + (popoverWidth / 2) // Right edge of popover
- var offset: CGFloat = 0 // Default = no shift
- // Push popover to right if its left edge is (nearing) out-of-bounds
- if x_left < 0 {
- offset = abs(x_left) // push to right
- }
- // Push popover to left if its right edge is (nearing) out-of-bounds)
- if x_right > chartWidth {
- offset = -(x_right - chartWidth) // push to left
- }
- return offset
- }
- var body: some View {
- VStack(alignment: .leading, spacing: 4) {
- Text(timeText)
- .font(.subheadline)
- .bold()
- .foregroundStyle(Color.secondary)
- Divider()
- HStack {
- Text(tdd.amount.formatted(.number.precision(.fractionLength(1))))
- Text("U").foregroundStyle(Color.secondary)
- }
- .font(.headline)
- }
- .padding(20)
- .background {
- RoundedRectangle(cornerRadius: 10)
- .fill(colorScheme == .dark ? Color.bgDarkBlue.opacity(0.9) : Color.white.opacity(0.95))
- .shadow(color: Color.secondary, radius: 2)
- .overlay(
- RoundedRectangle(cornerRadius: 4)
- .stroke(Color.blue, lineWidth: 2)
- )
- }
- .frame(minWidth: 100, maxWidth: .infinity) // Ensures proper width
- .background(
- GeometryReader { geo in
- Color.clear
- .onAppear { popoverSize = geo.size }
- .onChange(of: geo.size) { _, newValue in popoverSize = newValue }
- }
- )
- // Apply calculated xOffset to keep within bounds
- .offset(x: xOffset(), y: 0)
- }
- }
|