547 lines
18 KiB
Swift
547 lines
18 KiB
Swift
//
|
|
// AnalyticsView.swift
|
|
// OpenClimb
|
|
//
|
|
// Created by OpenClimb on 2025-01-17.
|
|
//
|
|
|
|
import SwiftUI
|
|
|
|
struct AnalyticsView: View {
|
|
@EnvironmentObject var dataManager: ClimbingDataManager
|
|
|
|
var body: some View {
|
|
NavigationView {
|
|
ScrollView {
|
|
LazyVStack(spacing: 20) {
|
|
HeaderSection()
|
|
|
|
OverallStatsSection()
|
|
|
|
ProgressChartSection()
|
|
|
|
HStack(spacing: 16) {
|
|
FavoriteGymSection()
|
|
|
|
RecentActivitySection()
|
|
}
|
|
}
|
|
.padding()
|
|
}
|
|
.navigationTitle("Analytics")
|
|
}
|
|
}
|
|
}
|
|
|
|
struct HeaderSection: View {
|
|
var body: some View {
|
|
HStack {
|
|
Image("MountainsIcon")
|
|
.resizable()
|
|
.frame(width: 32, height: 32)
|
|
|
|
Text("Analytics")
|
|
.font(.title)
|
|
.fontWeight(.bold)
|
|
|
|
Spacer()
|
|
}
|
|
}
|
|
}
|
|
|
|
struct OverallStatsSection: View {
|
|
@EnvironmentObject var dataManager: ClimbingDataManager
|
|
|
|
var body: some View {
|
|
VStack(alignment: .leading, spacing: 16) {
|
|
Text("Overall Stats")
|
|
.font(.title2)
|
|
.fontWeight(.bold)
|
|
|
|
LazyVGrid(columns: Array(repeating: GridItem(.flexible()), count: 2), spacing: 16) {
|
|
StatCard(
|
|
title: "Sessions",
|
|
value: "\(dataManager.completedSessions().count)",
|
|
icon: "play.fill",
|
|
color: .blue
|
|
)
|
|
|
|
StatCard(
|
|
title: "Problems",
|
|
value: "\(dataManager.problems.count)",
|
|
icon: "star.fill",
|
|
color: .orange
|
|
)
|
|
|
|
StatCard(
|
|
title: "Attempts",
|
|
value: "\(dataManager.totalAttempts())",
|
|
icon: "hand.raised.fill",
|
|
color: .green
|
|
)
|
|
|
|
StatCard(
|
|
title: "Gyms",
|
|
value: "\(dataManager.gyms.count)",
|
|
icon: "location.fill",
|
|
color: .purple
|
|
)
|
|
}
|
|
}
|
|
.padding()
|
|
.background(
|
|
RoundedRectangle(cornerRadius: 16)
|
|
.fill(.regularMaterial)
|
|
)
|
|
}
|
|
}
|
|
|
|
struct StatCard: View {
|
|
let title: String
|
|
let value: String
|
|
let icon: String
|
|
let color: Color
|
|
|
|
var body: some View {
|
|
VStack(spacing: 8) {
|
|
Image(systemName: icon)
|
|
.font(.title2)
|
|
.foregroundColor(color)
|
|
|
|
Text(value)
|
|
.font(.title)
|
|
.fontWeight(.bold)
|
|
.foregroundColor(.primary)
|
|
|
|
Text(title)
|
|
.font(.caption)
|
|
.foregroundColor(.secondary)
|
|
}
|
|
.frame(maxWidth: .infinity)
|
|
.padding()
|
|
.background(
|
|
RoundedRectangle(cornerRadius: 12)
|
|
.fill(.ultraThinMaterial)
|
|
)
|
|
}
|
|
}
|
|
|
|
struct ProgressChartSection: View {
|
|
@EnvironmentObject var dataManager: ClimbingDataManager
|
|
@State private var selectedSystem: DifficultySystem = .vScale
|
|
|
|
private var progressData: [ProgressDataPoint] {
|
|
calculateProgressOverTime()
|
|
}
|
|
|
|
private var usedSystems: [DifficultySystem] {
|
|
let uniqueSystems = Set(progressData.map { $0.difficultySystem })
|
|
return uniqueSystems.sorted {
|
|
let order: [DifficultySystem] = [.vScale, .font, .yds, .custom]
|
|
let firstIndex = order.firstIndex(of: $0) ?? order.count
|
|
let secondIndex = order.firstIndex(of: $1) ?? order.count
|
|
return firstIndex < secondIndex
|
|
}
|
|
}
|
|
|
|
var body: some View {
|
|
VStack(alignment: .leading, spacing: 16) {
|
|
HStack {
|
|
Text("Progress Over Time")
|
|
.font(.title2)
|
|
.fontWeight(.bold)
|
|
|
|
Spacer()
|
|
|
|
if usedSystems.count > 1 {
|
|
Menu {
|
|
ForEach(usedSystems, id: \.self) { system in
|
|
Button(action: {
|
|
selectedSystem = system
|
|
}) {
|
|
HStack {
|
|
Text(system.displayName)
|
|
if selectedSystem == system {
|
|
Spacer()
|
|
Image(systemName: "checkmark")
|
|
.foregroundColor(.blue)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
} label: {
|
|
HStack(spacing: 4) {
|
|
Text(selectedSystem.displayName)
|
|
.font(.subheadline)
|
|
.fontWeight(.medium)
|
|
Image(systemName: "chevron.down")
|
|
.font(.caption)
|
|
}
|
|
.padding(.horizontal, 12)
|
|
.padding(.vertical, 6)
|
|
.background(
|
|
RoundedRectangle(cornerRadius: 8)
|
|
.fill(.blue.opacity(0.1))
|
|
.stroke(.blue.opacity(0.3), lineWidth: 1)
|
|
)
|
|
.foregroundColor(.blue)
|
|
}
|
|
}
|
|
}
|
|
|
|
let filteredData = progressData.filter { $0.difficultySystem == selectedSystem }
|
|
|
|
if !filteredData.isEmpty {
|
|
LineChartView(data: filteredData, selectedSystem: selectedSystem)
|
|
.frame(height: 200)
|
|
|
|
Text(
|
|
"Progress: max \(selectedSystem.displayName.lowercased()) grade achieved per session"
|
|
)
|
|
.font(.caption)
|
|
.foregroundColor(.secondary)
|
|
} else {
|
|
VStack(spacing: 8) {
|
|
Image(systemName: "chart.line.uptrend.xyaxis")
|
|
.font(.title)
|
|
.foregroundColor(.secondary)
|
|
|
|
Text("No progress data available for \(selectedSystem.displayName) system")
|
|
.font(.subheadline)
|
|
.foregroundColor(.secondary)
|
|
}
|
|
.frame(height: 200)
|
|
.frame(maxWidth: .infinity)
|
|
}
|
|
}
|
|
.padding()
|
|
.background(
|
|
RoundedRectangle(cornerRadius: 16)
|
|
.fill(.regularMaterial)
|
|
)
|
|
.onAppear {
|
|
if let firstSystem = usedSystems.first {
|
|
selectedSystem = firstSystem
|
|
}
|
|
}
|
|
}
|
|
|
|
private func calculateProgressOverTime() -> [ProgressDataPoint] {
|
|
let sessions = dataManager.completedSessions().sorted { $0.date < $1.date }
|
|
let problems = dataManager.problems
|
|
let attempts = dataManager.attempts
|
|
|
|
return sessions.compactMap { session in
|
|
let sessionAttempts = attempts.filter { $0.sessionId == session.id }
|
|
let attemptedProblemIds = sessionAttempts.map { $0.problemId }
|
|
let attemptedProblems = problems.filter { attemptedProblemIds.contains($0.id) }
|
|
|
|
// Group problems by difficulty system
|
|
let problemsBySystem = Dictionary(grouping: attemptedProblems) { $0.difficulty.system }
|
|
|
|
// Create data points for each system used in this session
|
|
return problemsBySystem.compactMap { (system, systemProblems) -> ProgressDataPoint? in
|
|
guard
|
|
let highestGradeProblem = systemProblems.max(by: {
|
|
$0.difficulty.numericValue < $1.difficulty.numericValue
|
|
})
|
|
else {
|
|
return nil
|
|
}
|
|
|
|
return ProgressDataPoint(
|
|
date: session.date,
|
|
maxGrade: highestGradeProblem.difficulty.grade,
|
|
maxGradeNumeric: highestGradeProblem.difficulty.numericValue,
|
|
climbType: highestGradeProblem.climbType,
|
|
difficultySystem: system
|
|
)
|
|
}
|
|
}.flatMap { $0 }
|
|
}
|
|
}
|
|
|
|
struct FavoriteGymSection: View {
|
|
@EnvironmentObject var dataManager: ClimbingDataManager
|
|
|
|
private var favoriteGymInfo: (gym: Gym, sessionCount: Int)? {
|
|
let gymSessionCounts = Dictionary(grouping: dataManager.sessions, by: { $0.gymId })
|
|
.mapValues { $0.count }
|
|
|
|
guard let mostUsedGymId = gymSessionCounts.max(by: { $0.value < $1.value })?.key,
|
|
let gym = dataManager.gym(withId: mostUsedGymId)
|
|
else {
|
|
return nil
|
|
}
|
|
|
|
return (gym, gymSessionCounts[mostUsedGymId] ?? 0)
|
|
}
|
|
|
|
var body: some View {
|
|
VStack(alignment: .leading, spacing: 16) {
|
|
HStack {
|
|
Image(systemName: "location.fill")
|
|
.font(.title2)
|
|
.foregroundColor(.purple)
|
|
|
|
Text("Favorite Gym")
|
|
.font(.title2)
|
|
.fontWeight(.bold)
|
|
|
|
Spacer()
|
|
}
|
|
|
|
if let info = favoriteGymInfo {
|
|
VStack(alignment: .leading, spacing: 12) {
|
|
Text(info.gym.name)
|
|
.font(.title3)
|
|
.fontWeight(.semibold)
|
|
.foregroundColor(.primary)
|
|
|
|
HStack {
|
|
Image(systemName: "calendar")
|
|
.font(.subheadline)
|
|
.foregroundColor(.purple)
|
|
|
|
Text("\(info.sessionCount) sessions")
|
|
.font(.subheadline)
|
|
.foregroundColor(.secondary)
|
|
}
|
|
|
|
Spacer()
|
|
}
|
|
} else {
|
|
VStack(alignment: .leading, spacing: 8) {
|
|
Text("No sessions yet")
|
|
.font(.subheadline)
|
|
.foregroundColor(.secondary)
|
|
|
|
Text("Start climbing to see your favorite gym!")
|
|
.font(.caption)
|
|
.foregroundColor(.secondary)
|
|
|
|
Spacer()
|
|
}
|
|
}
|
|
}
|
|
.frame(maxWidth: .infinity, minHeight: 120, alignment: .topLeading)
|
|
.padding()
|
|
.background(
|
|
RoundedRectangle(cornerRadius: 16)
|
|
.fill(.regularMaterial)
|
|
)
|
|
}
|
|
}
|
|
|
|
struct RecentActivitySection: View {
|
|
@EnvironmentObject var dataManager: ClimbingDataManager
|
|
|
|
private var recentSessionsCount: Int {
|
|
dataManager.sessions.count
|
|
}
|
|
|
|
private var totalAttempts: Int {
|
|
dataManager.attempts.count
|
|
}
|
|
|
|
var body: some View {
|
|
VStack(alignment: .leading, spacing: 16) {
|
|
HStack {
|
|
Image(systemName: "clock.fill")
|
|
.font(.title2)
|
|
.foregroundColor(.blue)
|
|
|
|
Text("Recent Activity")
|
|
.font(.title2)
|
|
.fontWeight(.bold)
|
|
|
|
Spacer()
|
|
}
|
|
|
|
if recentSessionsCount > 0 {
|
|
VStack(alignment: .leading, spacing: 12) {
|
|
HStack {
|
|
Image(systemName: "play.circle")
|
|
.font(.subheadline)
|
|
.foregroundColor(.blue)
|
|
|
|
Text("\(recentSessionsCount) sessions")
|
|
.font(.subheadline)
|
|
.foregroundColor(.secondary)
|
|
}
|
|
|
|
HStack {
|
|
Image(systemName: "hand.raised")
|
|
.font(.subheadline)
|
|
.foregroundColor(.green)
|
|
|
|
Text("\(totalAttempts) attempts")
|
|
.font(.subheadline)
|
|
.foregroundColor(.secondary)
|
|
}
|
|
|
|
Spacer()
|
|
}
|
|
} else {
|
|
VStack(alignment: .leading, spacing: 8) {
|
|
Text("No recent activity")
|
|
.font(.subheadline)
|
|
.foregroundColor(.secondary)
|
|
|
|
Text("Start your first session!")
|
|
.font(.caption)
|
|
.foregroundColor(.secondary)
|
|
|
|
Spacer()
|
|
}
|
|
}
|
|
}
|
|
.frame(maxWidth: .infinity, minHeight: 120, alignment: .topLeading)
|
|
.padding()
|
|
.background(
|
|
RoundedRectangle(cornerRadius: 16)
|
|
.fill(.regularMaterial)
|
|
)
|
|
}
|
|
}
|
|
|
|
struct LineChartView: View {
|
|
let data: [ProgressDataPoint]
|
|
let selectedSystem: DifficultySystem
|
|
|
|
private var uniqueGrades: [String] {
|
|
if selectedSystem == .custom {
|
|
return Array(Set(data.map { $0.maxGrade })).sorted { grade1, grade2 in
|
|
return (Int(grade1) ?? 0) > (Int(grade2) ?? 0)
|
|
}
|
|
} else {
|
|
return Array(Set(data.map { $0.maxGrade })).sorted { grade1, grade2 in
|
|
let grade1Data = data.first(where: { $0.maxGrade == grade1 })
|
|
let grade2Data = data.first(where: { $0.maxGrade == grade2 })
|
|
return (grade1Data?.maxGradeNumeric ?? 0)
|
|
> (grade2Data?.maxGradeNumeric ?? 0)
|
|
}
|
|
}
|
|
}
|
|
|
|
private var minGrade: Int {
|
|
data.map { $0.maxGradeNumeric }.min() ?? 0
|
|
}
|
|
|
|
private var maxGrade: Int {
|
|
data.map { $0.maxGradeNumeric }.max() ?? 1
|
|
}
|
|
|
|
private var gradeRange: Int {
|
|
max(maxGrade - minGrade, 1)
|
|
}
|
|
|
|
var body: some View {
|
|
GeometryReader { geometry in
|
|
let chartWidth = geometry.size.width - 40
|
|
let chartHeight = geometry.size.height - 40
|
|
|
|
if data.isEmpty {
|
|
Rectangle()
|
|
.fill(.clear)
|
|
.overlay(
|
|
Text("No data")
|
|
.foregroundColor(.secondary)
|
|
)
|
|
} else {
|
|
|
|
HStack {
|
|
// Y-axis labels
|
|
VStack {
|
|
ForEach(0..<min(5, uniqueGrades.count), id: \.self) { i in
|
|
let gradeLabel = i < uniqueGrades.count ? uniqueGrades[i] : ""
|
|
|
|
Text(gradeLabel)
|
|
.font(.caption)
|
|
.foregroundColor(.secondary)
|
|
.frame(width: 30, alignment: .trailing)
|
|
|
|
if i < min(4, uniqueGrades.count - 1) {
|
|
Spacer()
|
|
}
|
|
}
|
|
}
|
|
.frame(height: chartHeight)
|
|
|
|
// Chart area
|
|
ZStack {
|
|
// Grid lines
|
|
ForEach(0..<5) { i in
|
|
let y = CGFloat(i) * chartHeight / 4
|
|
Rectangle()
|
|
.fill(.gray.opacity(0.2))
|
|
.frame(height: 0.5)
|
|
.offset(y: y - chartHeight / 2)
|
|
}
|
|
|
|
// Line chart
|
|
if data.count > 1 {
|
|
Path { path in
|
|
for (index, point) in data.enumerated() {
|
|
let x = CGFloat(index) * chartWidth / CGFloat(data.count - 1)
|
|
let normalizedY =
|
|
CGFloat(point.maxGradeNumeric - minGrade)
|
|
/ CGFloat(gradeRange)
|
|
let y = chartHeight - (normalizedY * chartHeight)
|
|
|
|
if index == 0 {
|
|
path.move(to: CGPoint(x: x, y: y))
|
|
} else {
|
|
path.addLine(to: CGPoint(x: x, y: y))
|
|
}
|
|
}
|
|
}
|
|
.stroke(.blue, lineWidth: 2)
|
|
}
|
|
|
|
// Data points
|
|
ForEach(data.indices, id: \.self) { index in
|
|
let point = data[index]
|
|
let x =
|
|
data.count == 1
|
|
? chartWidth / 2
|
|
: CGFloat(index) * chartWidth / CGFloat(data.count - 1)
|
|
let normalizedY =
|
|
CGFloat(point.maxGradeNumeric - minGrade) / CGFloat(gradeRange)
|
|
let y = chartHeight - (normalizedY * chartHeight)
|
|
|
|
Circle()
|
|
.fill(.blue)
|
|
.frame(width: 8, height: 8)
|
|
.position(x: x, y: y)
|
|
.overlay(
|
|
Circle()
|
|
.stroke(.white, lineWidth: 2)
|
|
.frame(width: 8, height: 8)
|
|
.position(x: x, y: y)
|
|
)
|
|
}
|
|
}
|
|
.frame(width: chartWidth, height: chartHeight)
|
|
}
|
|
}
|
|
}
|
|
.padding()
|
|
}
|
|
}
|
|
|
|
struct ProgressDataPoint {
|
|
let date: Date
|
|
let maxGrade: String
|
|
let maxGradeNumeric: Int
|
|
let climbType: ClimbType
|
|
let difficultySystem: DifficultySystem
|
|
}
|
|
|
|
// MARK: - Helper Functions
|
|
|
|
#Preview {
|
|
AnalyticsView()
|
|
.environmentObject(ClimbingDataManager.preview)
|
|
}
|