From 7f11b0e8c12a6090c0669d242a9edae122add1ed Mon Sep 17 00:00:00 2001 From: kutesir Date: Tue, 7 Jul 2026 23:23:54 +0300 Subject: [PATCH] analytics(ui): AnalyticsView (6 areas + states + charts) + Workout entry (KC-51 ph4b) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Self-contained Analytics UI: Overview/Daily/Weekly/Monthly/12-Month/Exercises with color+glyph status, empty/partial states, Swift Charts, VoiceOver labels, Wenza tokens, and the non-medical disclaimer. Entry via an IButton in the Workout header (sheet). Added Identifiable chart points. Self-reviewed for compile issues (fixed tuple key-paths, a11y trait literal, format string) but NOT build-verified — no iOS runtime; SwiftUI/app deps. Logged in ISSUES.md as device-required. Co-Authored-By: Claude Opus 4.8 --- KisaniCal.xcodeproj/project.pbxproj | 4 + KisaniCal/Analytics/AnalyticsModels.swift | 13 + KisaniCal/Analytics/AnalyticsService.swift | 9 +- KisaniCal/ISSUES.md | 19 + KisaniCal/Views/AnalyticsView.swift | 405 +++++++++++++++++++++ KisaniCal/Views/WorkoutView.swift | 7 + 6 files changed, 453 insertions(+), 4 deletions(-) create mode 100644 KisaniCal/Views/AnalyticsView.swift diff --git a/KisaniCal.xcodeproj/project.pbxproj b/KisaniCal.xcodeproj/project.pbxproj index f665cf1..78347ac 100644 --- a/KisaniCal.xcodeproj/project.pbxproj +++ b/KisaniCal.xcodeproj/project.pbxproj @@ -46,6 +46,7 @@ 8C9567A3DE3F63A1ECAE84D5 /* TutorialView.swift in Sources */ = {isa = PBXBuildFile; fileRef = FA3D5289C5F93484E22DEB63 /* TutorialView.swift */; }; 8DA396DACE99DC4B7B4A460E /* TaskActivityAttributes.swift in Sources */ = {isa = PBXBuildFile; fileRef = FEEB07EB89E89383C32ADB34 /* TaskActivityAttributes.swift */; }; 9070521B1D36A5551976C275 /* CalendarView.swift in Sources */ = {isa = PBXBuildFile; fileRef = ADF6CCD95A587E26E30F5712 /* CalendarView.swift */; }; + 9AA3E4808E88CA639BF3F28B /* AnalyticsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1C6CEBCB6041EA41ED482F67 /* AnalyticsView.swift */; }; 9E3F966F6B6AF2E4F8109E51 /* WorkoutView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 20DAD771EFCC8B3B1DBD4DD6 /* WorkoutView.swift */; }; A1DBC4D2F09C02B8CEE6449E /* AddExerciseSheet.swift in Sources */ = {isa = PBXBuildFile; fileRef = 768D4E314252E2A90A06CCF2 /* AddExerciseSheet.swift */; }; A292B0492135BA40F6B6A951 /* CalendarGrid.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0FCB2077D9509CB5C0978E58 /* CalendarGrid.swift */; }; @@ -113,6 +114,7 @@ 106EEF572C6F8990408329F0 /* OnboardingView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OnboardingView.swift; sourceTree = ""; }; 10BD058FC3875B6BD6B10C90 /* AnalyticsStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AnalyticsStore.swift; sourceTree = ""; }; 1AA7498EFED7692022F3E7E1 /* KisaniCal.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = KisaniCal.entitlements; sourceTree = ""; }; + 1C6CEBCB6041EA41ED482F67 /* AnalyticsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AnalyticsView.swift; sourceTree = ""; }; 208F82348DEBD9FF7B0DCF17 /* MyTasksWidget.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MyTasksWidget.swift; sourceTree = ""; }; 20DAD771EFCC8B3B1DBD4DD6 /* WorkoutView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WorkoutView.swift; sourceTree = ""; }; 20E29A9264577A89891DAEC1 /* KisaniCalTests.xctest */ = {isa = PBXFileReference; includeInIndex = 0; lastKnownFileType = wrapper.cfbundle; path = KisaniCalTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; @@ -243,6 +245,7 @@ children = ( 69F0950E6F31016C848B2A63 /* ActivityHistoryView.swift */, 768D4E314252E2A90A06CCF2 /* AddExerciseSheet.swift */, + 1C6CEBCB6041EA41ED482F67 /* AnalyticsView.swift */, 4AD014B7E3E30A34E18696A0 /* AuthView.swift */, ADF6CCD95A587E26E30F5712 /* CalendarView.swift */, D44530A77DF12A17E52AAF34 /* MatrixView.swift */, @@ -514,6 +517,7 @@ 42E3B90A981E75FFC10C221F /* AnalyticsModels.swift in Sources */, 5F91DDE0B64C8AE1F142F434 /* AnalyticsService.swift in Sources */, E6FCFC0D429627201A52C616 /* AnalyticsStore.swift in Sources */, + 9AA3E4808E88CA639BF3F28B /* AnalyticsView.swift in Sources */, 2CD2313EDC4BFCE6E89C6787 /* AppGroup.swift in Sources */, 3B57EA3600AFE975850DF39A /* AuthManager.swift in Sources */, ECEAA5CF7309E5993D12B571 /* AuthView.swift in Sources */, diff --git a/KisaniCal/Analytics/AnalyticsModels.swift b/KisaniCal/Analytics/AnalyticsModels.swift index 3a284b3..09f9dde 100644 --- a/KisaniCal/Analytics/AnalyticsModels.swift +++ b/KisaniCal/Analytics/AnalyticsModels.swift @@ -143,6 +143,19 @@ struct WeeklyReport: Codable, Equatable, Identifiable { var id: String { weekStartKey } } +// Chart-friendly Identifiable points (tuples can't be keyed in SwiftUI Charts). +struct MonthVolumePoint: Identifiable, Equatable { + let monthKey: String + let volume: Double + var id: String { monthKey } +} + +struct ExerciseVolumePoint: Identifiable, Equatable { + let dateKey: String + let volume: Double + var id: String { dateKey } +} + struct MonthlyReport: Codable, Equatable, Identifiable { let monthKey: String // "yyyy-MM" let generatedAt: Date diff --git a/KisaniCal/Analytics/AnalyticsService.swift b/KisaniCal/Analytics/AnalyticsService.swift index 1c2cff8..36331ec 100644 --- a/KisaniCal/Analytics/AnalyticsService.swift +++ b/KisaniCal/Analytics/AnalyticsService.swift @@ -131,8 +131,9 @@ final class AnalyticsService: ObservableObject { func monthlyReports() -> [MonthlyReport] { store.allMonthlyReports().sorted { $0.monthKey > $1.monthKey } } /// 12-month volume series (oldest→newest) for the trends screen. - func monthlyVolumeTrend() -> [(monthKey: String, volume: Double)] { - store.allMonthlyReports().sorted { $0.monthKey < $1.monthKey }.map { ($0.monthKey, $0.summary.volume) } + func monthlyVolumeTrend() -> [MonthVolumePoint] { + store.allMonthlyReports().sorted { $0.monthKey < $1.monthKey } + .map { MonthVolumePoint(monthKey: $0.monthKey, volume: $0.summary.volume) } } func monthlyTrendClassification() -> TrendResult { @@ -151,11 +152,11 @@ final class AnalyticsService: ObservableObject { } /// Per-exercise volume history (dateKey→volume) across stored snapshots. - func exerciseHistory(identity: String) -> [(dateKey: String, volume: Double)] { + func exerciseHistory(identity: String) -> [ExerciseVolumePoint] { store.allSnapshotKeys().sorted().compactMap { key in guard let sample = store.snapshot(dateKey: key), let ex = sample.exercises.first(where: { $0.identity == identity }) else { return nil } - return (key, ex.volume) + return ExerciseVolumePoint(dateKey: key, volume: ex.volume) } } diff --git a/KisaniCal/ISSUES.md b/KisaniCal/ISSUES.md index d4577ad..fbcdfdb 100644 --- a/KisaniCal/ISSUES.md +++ b/KisaniCal/ISSUES.md @@ -1608,3 +1608,22 @@ last. Device/build QA tracked here. - ⚠️ NOT compiled/build-verified (no iOS runtime + app-module/SwiftUI deps). Time-of-day "user-scheduled" report notification (vs deliver-on-detect) and historical HealthKit reference are documented follow-ups. + +### KC-51 progress — Phase 4b: Analytics UI (NOT build-verified) +- `AnalyticsView` (self-contained): 6 areas — Overview / Daily / Weekly / Monthly + / 12-Month / Exercises — via a chip selector. Reads `AnalyticsService`. + - Status by BOTH color and glyph (arrow.up.right / arrow.down.right / equal / + minus) — never color alone. Empty + partial ("not enough data") states. + - Swift Charts for 12-month volume (bar) + per-exercise volume (line). + - Informational non-medical disclaimer (`AnalyticsEngine.disclaimer`) footer. + - VoiceOver labels on badges/chips/charts; Wenza tokens (auto light/dark). +- Entry point: an Analytics `IButton` (chart.line.uptrend) in the Workout header + → presents `AnalyticsView` as a sheet (mirrors the existing history/stats). +- Added Chart-friendly `MonthVolumePoint` / `ExerciseVolumePoint` (tuples can't be + keyed in Charts). +- Self-review caught + fixed pre-emptively (no build here): tuple key-paths in + `Chart(id:)`, accessibility-traits array literal, `%@` format string. +- ⚠️ NOT compiled — SwiftUI + app-module deps; needs an Xcode build. Likely still + has issues only a compiler will surface. Remaining UI polish: deep-link routing + into the presented view, Dynamic-Type audit, denied-permission state for future + HealthKit trends. diff --git a/KisaniCal/Views/AnalyticsView.swift b/KisaniCal/Views/AnalyticsView.swift new file mode 100644 index 0000000..39c6825 --- /dev/null +++ b/KisaniCal/Views/AnalyticsView.swift @@ -0,0 +1,405 @@ +import SwiftUI +import Charts + +// Analytics area: Overview · Daily · Weekly · Monthly · 12-Month · Exercises. +// Reads from AnalyticsService (which reconciles reports elsewhere). Status is +// conveyed by BOTH color and a glyph (never color alone). Includes empty/partial +// states and an informational, non-medical disclaimer. +// +// NOTE: written without an available build in this environment — verify on device. +struct AnalyticsView: View { + @Environment(\.colorScheme) private var cs + @ObservedObject private var service = AnalyticsService.shared + @State private var area: Area = .overview + @State private var selectedExercise: String? + + enum Area: String, CaseIterable, Identifiable { + case overview = "Overview", daily = "Daily", weekly = "Weekly" + case monthly = "Monthly", trends = "12-Month", exercises = "Exercises" + var id: String { rawValue } + } + + private var hasAnyReports: Bool { + !service.weeklyReports().isEmpty || !service.monthlyReports().isEmpty + } + + var body: some View { + VStack(spacing: 0) { + header + areaSelector + Divider().background(AppColors.border(cs)) + ScrollView(showsIndicators: false) { + VStack(alignment: .leading, spacing: 16) { + content + disclaimer + } + .padding(16) + .padding(.bottom, 40) + } + } + .background(AppColors.background(cs).ignoresSafeArea()) + } + + private var header: some View { + Text("Analytics") + .font(AppFonts.sans(20, weight: .bold)) + .foregroundColor(AppColors.text(cs)) + .frame(maxWidth: .infinity, alignment: .leading) + .padding(.horizontal, 16).padding(.top, 18).padding(.bottom, 8) + } + + private var areaSelector: some View { + ScrollView(.horizontal, showsIndicators: false) { + HStack(spacing: 8) { + ForEach(Area.allCases) { a in + let on = a == area + Text(a.rawValue) + .font(AppFonts.sans(12, weight: .semibold)) + .foregroundColor(on ? .white : AppColors.text2(cs)) + .padding(.horizontal, 12).padding(.vertical, 7) + .background(on ? AppColors.accent : AppColors.surface2(cs)) + .clipShape(Capsule()) + .contentShape(Capsule()) + .onTapGesture { withAnimation(KisaniSpring.micro) { area = a } } + .accessibilityLabel(a.rawValue) + .accessibilityAddTraits(on ? [.isButton, .isSelected] : [.isButton]) + } + } + .padding(.horizontal, 16).padding(.vertical, 10) + } + } + + @ViewBuilder private var content: some View { + if !hasAnyReports && area != .daily { + emptyState + } else { + switch area { + case .overview: overview + case .daily: dailySection + case .weekly: weeklySection + case .monthly: monthlySection + case .trends: trendsSection + case .exercises: exercisesSection + } + } + } + + // MARK: - Empty state + + private var emptyState: some View { + VStack(spacing: 8) { + Image(systemName: "chart.line.uptrend.xyaxis") + .font(.system(size: 30)).foregroundColor(AppColors.text3(cs)) + Text("No reports yet") + .font(AppFonts.sans(15, weight: .semibold)).foregroundColor(AppColors.text2(cs)) + Text("As each week and month completes, an immutable summary appears here.") + .font(AppFonts.sans(12)).foregroundColor(AppColors.text3(cs)) + .multilineTextAlignment(.center) + } + .frame(maxWidth: .infinity).padding(.vertical, 60) + } + + // MARK: - Overview + + private var overview: some View { + VStack(alignment: .leading, spacing: 16) { + if let w = service.weeklyReports().first { + sectionTitle("Latest week") + weeklyCard(w) + } + if let m = service.monthlyReports().first { + sectionTitle("Latest month") + monthlyCard(m) + } + sectionTitle("12-month direction") + HStack { + trendBadge(service.monthlyTrendClassification().classification) + Spacer() + Text("\(service.monthlyReports().count) mo · \(service.weeklyReports().count) wk") + .font(AppFonts.mono(10)).foregroundColor(AppColors.text3(cs)) + } + .padding(12).cardBG(cs) + } + } + + // MARK: - Daily + + private var dailySection: some View { + let cmp = service.dailyComparison(on: Date()) + return VStack(alignment: .leading, spacing: 16) { + if cmp.restDay { + infoCard("Rest day", "No workout logged today — nothing to compare.") + } + comparisonGroup("vs. yesterday", cmp.vsPreviousDay) + comparisonGroup("vs. same day last week", cmp.vsSameWeekdayLastWeek) + } + } + + private func comparisonGroup(_ title: String, _ comps: [ExerciseComparison]) -> some View { + VStack(alignment: .leading, spacing: 8) { + sectionTitle(title) + if comps.isEmpty { + infoCard("Nothing to compare", "No matching exercises for this comparison.") + } else { + ForEach(comps, id: \.identity) { c in + HStack { + Text(c.identity.capitalized) + .font(AppFonts.sans(13, weight: .medium)).foregroundColor(AppColors.text(cs)) + if c.isNew { + Text("NEW").font(AppFonts.mono(8, weight: .bold)) + .foregroundColor(AppColors.blue) + .padding(.horizontal, 5).padding(.vertical, 2) + .background(AppColors.blueSoft).clipShape(Capsule()) + } + Spacer() + deltaBadge(c.volume, unit: "kg") + } + .padding(12).cardBG(cs) + } + } + } + } + + // MARK: - Weekly / Monthly + + private var weeklySection: some View { + VStack(alignment: .leading, spacing: 12) { + ForEach(service.weeklyReports()) { weeklyCard($0) } + } + } + + private var monthlySection: some View { + VStack(alignment: .leading, spacing: 12) { + ForEach(service.monthlyReports()) { monthlyCard($0) } + } + } + + private func weeklyCard(_ r: WeeklyReport) -> some View { + VStack(alignment: .leading, spacing: 10) { + HStack { + Text("Week of \(r.weekStartKey)") + .font(AppFonts.sans(13, weight: .semibold)).foregroundColor(AppColors.text(cs)) + Spacer() + trendBadge(r.progression) + } + summaryRow(r.summary) + deltaChips(r.vsPreviousWeek) + } + .padding(14).cardBG(cs) + } + + private func monthlyCard(_ r: MonthlyReport) -> some View { + VStack(alignment: .leading, spacing: 10) { + HStack { + Text(r.monthKey) + .font(AppFonts.sans(13, weight: .semibold)).foregroundColor(AppColors.text(cs)) + Spacer() + trendBadge(r.classification) + } + summaryRow(r.summary) + deltaChips(r.vsPreviousMonth) + } + .padding(14).cardBG(cs) + } + + private func summaryRow(_ s: PeriodSummary) -> some View { + HStack(spacing: 14) { + stat("\(s.sessions)", "sessions") + stat("\(s.sets)", "sets") + stat("\(Int(s.volume))", "kg") + stat("\(Int(s.consistency * 100))%", "consistent") + if s.missed > 0 { stat("\(s.missed)", "missed") } + } + } + + private func deltaChips(_ deltas: [String: MetricDelta]) -> some View { + let order = ["volume", "sessions", "sets", "duration", "consistency"] + return FlowRow(order.compactMap { key -> (String, MetricDelta)? in + guard let d = deltas[key], d.meaningful, d.percent != nil else { return nil } + return (key, d) + }) { pair in + HStack(spacing: 4) { + Text(pair.0).font(AppFonts.mono(9)).foregroundColor(AppColors.text3(cs)) + deltaBadge(pair.1, unit: "") + } + .padding(.horizontal, 8).padding(.vertical, 4) + .background(AppColors.surface2(cs)).clipShape(Capsule()) + } + } + + // MARK: - 12-Month trends + + private var trendsSection: some View { + let series = service.monthlyVolumeTrend() + return VStack(alignment: .leading, spacing: 12) { + HStack { + sectionTitle("Monthly volume") + Spacer() + trendBadge(service.monthlyTrendClassification().classification) + } + if series.isEmpty { + infoCard("Not enough data", "A few completed months are needed to chart a trend.") + } else { + Chart(series) { point in + BarMark( + x: .value("Month", point.monthKey), + y: .value("Volume", point.volume) + ) + .foregroundStyle(AppColors.accent) + } + .frame(height: 200) + .padding(12).cardBG(cs) + .accessibilityLabel("Monthly training volume for the last year") + } + } + } + + // MARK: - Per-exercise + + private var exercisesSection: some View { + let names = service.knownExerciseIdentities() + return VStack(alignment: .leading, spacing: 12) { + if names.isEmpty { + infoCard("No exercise history", "Logged exercises will appear here over time.") + } else { + Menu { + ForEach(names, id: \.self) { n in + Button(n.capitalized) { selectedExercise = n } + } + } label: { + HStack { + Text((selectedExercise ?? names.first ?? "").capitalized) + .font(AppFonts.sans(13, weight: .semibold)).foregroundColor(AppColors.text(cs)) + Image(systemName: "chevron.down").font(.system(size: 11)).foregroundColor(AppColors.text3(cs)) + Spacer() + } + .padding(12).cardBG(cs) + } + let id = selectedExercise ?? names.first ?? "" + let history = service.exerciseHistory(identity: id) + if history.count < 2 { + infoCard("Not enough history", "Log this exercise on more days to see a trend.") + } else { + Chart(history) { point in + LineMark(x: .value("Date", point.dateKey), y: .value("Volume", point.volume)) + .foregroundStyle(AppColors.accent) + PointMark(x: .value("Date", point.dateKey), y: .value("Volume", point.volume)) + .foregroundStyle(AppColors.accent) + } + .frame(height: 200) + .padding(12).cardBG(cs) + .accessibilityLabel("Volume history for \(id)") + } + } + } + } + + // MARK: - Reusable bits + + private func sectionTitle(_ t: String) -> some View { + Text(t.uppercased()) + .font(AppFonts.mono(9, weight: .bold)).foregroundColor(AppColors.text3(cs)) + } + + private func stat(_ value: String, _ label: String) -> some View { + VStack(alignment: .leading, spacing: 1) { + Text(value).font(AppFonts.sans(15, weight: .bold)).foregroundColor(AppColors.text(cs)) + Text(label).font(AppFonts.mono(8)).foregroundColor(AppColors.text3(cs)) + } + } + + private func infoCard(_ title: String, _ body: String) -> some View { + VStack(alignment: .leading, spacing: 3) { + Text(title).font(AppFonts.sans(13, weight: .semibold)).foregroundColor(AppColors.text2(cs)) + Text(body).font(AppFonts.sans(11)).foregroundColor(AppColors.text3(cs)) + } + .frame(maxWidth: .infinity, alignment: .leading) + .padding(12).cardBG(cs) + } + + /// Direction shown by glyph + sign + color (glyph works without color). + private func deltaBadge(_ d: MetricDelta, unit: String) -> some View { + let (glyph, color): (String, Color) = { + switch d.direction { + case .up: return ("arrow.up.right", AppColors.green) + case .down: return ("arrow.down.right", AppColors.red) + case .flat: return ("equal", AppColors.text3(cs)) + case .none: return ("minus", AppColors.text3(cs)) + } + }() + let text: String = { + if let p = d.percent { return String(format: "%+.0f%%", p) } + if let a = d.absolute { + let u = unit.isEmpty ? "" : " " + unit + return String(format: "%+.0f", a) + u + } + return "—" + }() + return HStack(spacing: 3) { + Image(systemName: glyph).font(.system(size: 9, weight: .bold)) + Text(text).font(AppFonts.mono(11, weight: .semibold)) + } + .foregroundColor(d.meaningful ? color : AppColors.text3(cs)) + .accessibilityElement(children: .combine) + .accessibilityLabel("\(d.direction.rawValue) \(text)") + } + + private func trendBadge(_ t: TrendClassification) -> some View { + let (glyph, color, label): (String, Color, String) = { + switch t { + case .improving: return ("arrow.up.right", AppColors.green, "Improving") + case .declining: return ("arrow.down.right", AppColors.red, "Declining") + case .plateau: return ("equal", AppColors.text2(cs), "Plateau") + case .insufficientData: return ("minus", AppColors.text3(cs), "Not enough data") + } + }() + return HStack(spacing: 4) { + Image(systemName: glyph).font(.system(size: 9, weight: .bold)) + Text(label).font(AppFonts.mono(9, weight: .bold)) + } + .foregroundColor(color) + .padding(.horizontal, 8).padding(.vertical, 4) + .background(color.opacity(0.12)).clipShape(Capsule()) + .accessibilityElement(children: .combine) + .accessibilityLabel("Trend: \(label)") + } + + private var disclaimer: some View { + Text(AnalyticsEngine.disclaimer) + .font(AppFonts.sans(10)).foregroundColor(AppColors.text3(cs)) + .frame(maxWidth: .infinity, alignment: .leading) + .padding(.top, 8) + } +} + +// A minimal wrapping row (chips flow to new lines). +private struct FlowRow: View { + let items: Data + let content: (Data.Element) -> Content + init(_ items: Data, @ViewBuilder content: @escaping (Data.Element) -> Content) { + self.items = items; self.content = content + } + var body: some View { + // Simple 2-per-row grid is enough for the small delta-chip set. + let arr = Array(items) + return VStack(alignment: .leading, spacing: 6) { + ForEach(Array(stride(from: 0, to: arr.count, by: 2)), id: \.self) { i in + HStack(spacing: 6) { + content(arr[i]) + if i + 1 < arr.count { content(arr[i + 1]) } + Spacer(minLength: 0) + } + } + } + } +} + +private extension View { + /// Standard card background used across the analytics screens. + func cardBG(_ cs: ColorScheme) -> some View { + self.frame(maxWidth: .infinity, alignment: .leading) + .background(AppColors.surface(cs)) + .clipShape(RoundedRectangle(cornerRadius: AppRadius.large)) + .overlay(RoundedRectangle(cornerRadius: AppRadius.large).stroke(AppColors.border(cs), lineWidth: 1)) + } +} diff --git a/KisaniCal/Views/WorkoutView.swift b/KisaniCal/Views/WorkoutView.swift index a0b8f12..989e231 100644 --- a/KisaniCal/Views/WorkoutView.swift +++ b/KisaniCal/Views/WorkoutView.swift @@ -64,6 +64,7 @@ struct WorkoutView: View { @State private var showAddSection = false @State private var showHistory = false @State private var showStats = false + @State private var showAnalytics = false @State private var isReordering = false @State private var trainAnyway = false // override the rest-day state for today @ObservedObject private var tm = TutorialManager.shared @@ -103,6 +104,7 @@ struct WorkoutView: View { .foregroundColor(AppColors.text3(cs)) } Spacer() + IButton(icon: "chart.line.uptrend.xyaxis") { showAnalytics = true } IButton(icon: "chart.bar.xaxis") { showStats = true } IButton(icon: "clock.arrow.circlepath") { showHistory = true } Menu { @@ -324,6 +326,11 @@ struct WorkoutView: View { .presentationDetents([.large]) .presentationDragIndicator(.visible) } + .sheet(isPresented: $showAnalytics) { + AnalyticsView() + .presentationDetents([.large]) + .presentationDragIndicator(.visible) + } .sheet(isPresented: $showStats) { WorkoutStatsView().environmentObject(vm) .presentationDetents([.large])