analytics(ui): AnalyticsView (6 areas + states + charts) + Workout entry (KC-51 ph4b)
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 <noreply@anthropic.com>
This commit is contained in:
@@ -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 = "<group>"; };
|
||||
10BD058FC3875B6BD6B10C90 /* AnalyticsStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AnalyticsStore.swift; sourceTree = "<group>"; };
|
||||
1AA7498EFED7692022F3E7E1 /* KisaniCal.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = KisaniCal.entitlements; sourceTree = "<group>"; };
|
||||
1C6CEBCB6041EA41ED482F67 /* AnalyticsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AnalyticsView.swift; sourceTree = "<group>"; };
|
||||
208F82348DEBD9FF7B0DCF17 /* MyTasksWidget.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MyTasksWidget.swift; sourceTree = "<group>"; };
|
||||
20DAD771EFCC8B3B1DBD4DD6 /* WorkoutView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WorkoutView.swift; sourceTree = "<group>"; };
|
||||
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 */,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
405
KisaniCal/Views/AnalyticsView.swift
Normal file
405
KisaniCal/Views/AnalyticsView.swift
Normal file
@@ -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<Data: RandomAccessCollection, Content: View>: 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))
|
||||
}
|
||||
}
|
||||
@@ -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])
|
||||
|
||||
Reference in New Issue
Block a user