Files
Ascently/ios/OpenClimb/Views/AnalyticsView.swift

548 lines
19 KiB
Swift

import SwiftUI
struct AnalyticsView: View {
@EnvironmentObject var dataManager: ClimbingDataManager
var body: some View {
NavigationStack {
ScrollView {
LazyVStack(spacing: 20) {
OverallStatsSection()
ProgressChartSection()
HStack(spacing: 16) {
FavoriteGymSection()
RecentActivitySection()
}
}
.padding()
}
.navigationTitle("Analytics")
.toolbar {
ToolbarItem(placement: .navigationBarTrailing) {
if dataManager.isSyncing {
HStack(spacing: 2) {
ProgressView()
.progressViewStyle(CircularProgressViewStyle(tint: .blue))
.scaleEffect(0.6)
}
.padding(.horizontal, 6)
.padding(.vertical, 3)
.background(
Circle()
.fill(.regularMaterial)
)
.transition(.scale.combined(with: .opacity))
.animation(
.easeInOut(duration: 0.2), value: dataManager.isSyncing
)
}
}
}
}
}
}
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
@State private var showAllTime: Bool = true
@State private var cachedGradeCountData: [GradeCount] = []
@State private var lastCalculationDate: Date = Date.distantPast
@State private var lastDataHash: Int = 0
private var gradeCountData: [GradeCount] {
let currentHash =
dataManager.problems.count + dataManager.attempts.count + (showAllTime ? 1 : 0)
let now = Date()
// Recalculate only if data changed or cache is older than 30 seconds
if currentHash != lastDataHash || now.timeIntervalSince(lastCalculationDate) > 30 {
let newData = calculateGradeCounts()
DispatchQueue.main.async {
self.cachedGradeCountData = newData
self.lastCalculationDate = now
self.lastDataHash = currentHash
}
}
return cachedGradeCountData.isEmpty ? calculateGradeCounts() : cachedGradeCountData
}
private var usedSystems: [DifficultySystem] {
let uniqueSystems = Set(gradeCountData.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) {
Text("Grade Distribution")
.font(.title2)
.fontWeight(.bold)
// Toggles section
HStack {
// Time period toggle
HStack(spacing: 8) {
Button(action: {
showAllTime = true
}) {
Text("All Time")
.font(.caption)
.fontWeight(.medium)
.padding(.horizontal, 8)
.padding(.vertical, 4)
.background(
RoundedRectangle(cornerRadius: 6)
.fill(showAllTime ? .blue : .clear)
.stroke(.blue.opacity(0.3), lineWidth: 1)
)
.foregroundColor(showAllTime ? .white : .blue)
}
Button(action: {
showAllTime = false
}) {
Text("7 Days")
.font(.caption)
.fontWeight(.medium)
.padding(.horizontal, 8)
.padding(.vertical, 4)
.background(
RoundedRectangle(cornerRadius: 6)
.fill(!showAllTime ? .blue : .clear)
.stroke(.blue.opacity(0.3), lineWidth: 1)
)
.foregroundColor(!showAllTime ? .white : .blue)
}
}
Spacer()
// Scale selector (only show if multiple systems)
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 = gradeCountData.filter { $0.difficultySystem == selectedSystem }
if !filteredData.isEmpty {
BarChartView(data: filteredData)
.frame(height: 200)
Text("Successful climbs by grade")
.font(.caption)
.foregroundColor(.secondary)
} else {
VStack(spacing: 8) {
Image(systemName: "chart.bar")
.font(.title)
.foregroundColor(.secondary)
Text("No 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 calculateGradeCounts() -> [GradeCount] {
let problems = dataManager.problems
let attempts = dataManager.attempts
// Filter attempts by time period
let filteredAttempts: [Attempt]
if showAllTime {
filteredAttempts = attempts.filter { $0.result.isSuccessful }
} else {
let sevenDaysAgo =
Calendar.current.date(byAdding: .day, value: -7, to: Date()) ?? Date()
filteredAttempts = attempts.filter {
$0.result.isSuccessful && $0.timestamp >= sevenDaysAgo
}
}
// Get attempted problems
let attemptedProblemIds = filteredAttempts.map { $0.problemId }
let attemptedProblems = problems.filter { attemptedProblemIds.contains($0.id) }
// Group by difficulty system and grade
var gradeCounts: [String: GradeCount] = [:]
for problem in attemptedProblems {
let successfulAttemptsForProblem = filteredAttempts.filter {
$0.problemId == problem.id
}
let count = successfulAttemptsForProblem.count
let key = "\(problem.difficulty.system.rawValue)-\(problem.difficulty.grade)"
if let existing = gradeCounts[key] {
gradeCounts[key] = GradeCount(
grade: existing.grade,
count: existing.count + count,
gradeNumeric: existing.gradeNumeric,
difficultySystem: existing.difficultySystem
)
} else {
gradeCounts[key] = GradeCount(
grade: problem.difficulty.grade,
count: count,
gradeNumeric: problem.difficulty.numericValue,
difficultySystem: problem.difficulty.system
)
}
}
return Array(gradeCounts.values)
}
}
struct GradeCount {
let grade: String
let count: Int
let gradeNumeric: Int
let difficultySystem: DifficultySystem
}
struct BarChartView: View {
let data: [GradeCount]
private var sortedData: [GradeCount] {
data.sorted { $0.gradeNumeric < $1.gradeNumeric }
}
private var maxCount: Int {
data.map { $0.count }.max() ?? 1
}
var body: some View {
GeometryReader { geometry in
let chartWidth = geometry.size.width - 40
let chartHeight = geometry.size.height - 40
let barWidth = chartWidth / CGFloat(max(sortedData.count, 1)) * 0.8
let spacing = chartWidth / CGFloat(max(sortedData.count, 1)) * 0.2
if sortedData.isEmpty {
Rectangle()
.fill(.clear)
.overlay(
Text("No data")
.foregroundColor(.secondary)
)
} else {
VStack(alignment: .leading) {
// Chart area
HStack(alignment: .bottom, spacing: spacing / CGFloat(sortedData.count)) {
ForEach(Array(sortedData.enumerated()), id: \.offset) { index, gradeCount in
VStack(spacing: 4) {
// Bar
RoundedRectangle(cornerRadius: 4)
.fill(.blue)
.frame(
width: barWidth,
height: CGFloat(gradeCount.count) / CGFloat(maxCount)
* chartHeight * 0.8
)
.overlay(
Text("\(gradeCount.count)")
.font(.caption2)
.fontWeight(.medium)
.foregroundColor(.white)
.opacity(gradeCount.count > 0 ? 1 : 0)
)
// Grade label
Text(gradeCount.grade)
.font(.caption2)
.foregroundColor(.secondary)
.lineLimit(1)
}
}
}
.frame(height: chartHeight)
}
.frame(maxWidth: .infinity, alignment: .center)
}
}
}
}
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)
)
}
}
#Preview {
AnalyticsView()
.environmentObject(ClimbingDataManager.preview)
}