554 lines
19 KiB
Swift
554 lines
19 KiB
Swift
import SwiftUI
|
|
|
|
struct AnalyticsView: View {
|
|
@EnvironmentObject var dataManager: ClimbingDataManager
|
|
@EnvironmentObject var themeManager: ThemeManager
|
|
|
|
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: themeManager.accentColor))
|
|
.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
|
|
@EnvironmentObject var themeManager: ThemeManager
|
|
|
|
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: themeManager.accentColor
|
|
)
|
|
|
|
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(Color(uiColor: .secondarySystemGroupedBackground))
|
|
.shadow(color: .black.opacity(0.05), radius: 2, x: 0, y: 1)
|
|
)
|
|
}
|
|
}
|
|
|
|
struct ProgressChartSection: View {
|
|
@EnvironmentObject var dataManager: ClimbingDataManager
|
|
@EnvironmentObject var themeManager: ThemeManager
|
|
@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 ? themeManager.accentColor : .clear)
|
|
.stroke(themeManager.accentColor.opacity(0.3), lineWidth: 1)
|
|
)
|
|
.foregroundColor(showAllTime ? themeManager.contrastingTextColor : themeManager.accentColor)
|
|
}
|
|
|
|
Button(action: {
|
|
showAllTime = false
|
|
}) {
|
|
Text("7 Days")
|
|
.font(.caption)
|
|
.fontWeight(.medium)
|
|
.padding(.horizontal, 8)
|
|
.padding(.vertical, 4)
|
|
.background(
|
|
RoundedRectangle(cornerRadius: 6)
|
|
.fill(!showAllTime ? themeManager.accentColor : .clear)
|
|
.stroke(themeManager.accentColor.opacity(0.3), lineWidth: 1)
|
|
)
|
|
.foregroundColor(!showAllTime ? themeManager.contrastingTextColor : themeManager.accentColor)
|
|
}
|
|
}
|
|
|
|
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(themeManager.accentColor)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
} 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(themeManager.accentColor.opacity(0.1))
|
|
.stroke(themeManager.accentColor.opacity(0.3), lineWidth: 1)
|
|
)
|
|
.foregroundColor(themeManager.accentColor)
|
|
}
|
|
}
|
|
}
|
|
|
|
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.")
|
|
.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]
|
|
@EnvironmentObject var themeManager: ThemeManager
|
|
|
|
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(themeManager.accentColor)
|
|
.frame(
|
|
width: barWidth,
|
|
height: CGFloat(gradeCount.count) / CGFloat(maxCount)
|
|
* chartHeight * 0.8
|
|
)
|
|
.overlay(
|
|
Text("\(gradeCount.count)")
|
|
.font(.caption2)
|
|
.fontWeight(.medium)
|
|
.foregroundColor(themeManager.contrastingTextColor)
|
|
.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
|
|
@EnvironmentObject var themeManager: ThemeManager
|
|
|
|
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(themeManager.accentColor)
|
|
|
|
Text("Recent Activity")
|
|
.font(.title2)
|
|
.fontWeight(.bold)
|
|
|
|
Spacer()
|
|
}
|
|
|
|
if recentSessionsCount > 0 {
|
|
VStack(alignment: .leading, spacing: 12) {
|
|
HStack {
|
|
Image(systemName: "play.circle")
|
|
.font(.subheadline)
|
|
.foregroundColor(themeManager.accentColor)
|
|
|
|
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)
|
|
}
|