1.5.0 Initial run as iOS in a monorepo

This commit is contained in:
2025-09-12 22:35:14 -06:00
parent f106244e57
commit 7da1893748
127 changed files with 7062 additions and 1039 deletions

View File

@@ -0,0 +1,407 @@
//
// 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()
FavoriteGymSection()
RecentActivitySection()
}
.padding()
}
.navigationTitle("Analytics")
}
}
}
struct HeaderSection: View {
var body: some View {
HStack {
Image(systemName: "mountain.2.fill")
.font(.title)
.foregroundColor(.blue)
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] {
Set(progressData.map { $0.difficultySystem }).sorted { $0.rawValue < $1.rawValue }
}
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(system.displayName) {
selectedSystem = system
}
}
} label: {
Text(selectedSystem.displayName)
.font(.caption)
.padding(.horizontal, 8)
.padding(.vertical, 4)
.background(
RoundedRectangle(cornerRadius: 8)
.fill(.blue.opacity(0.1))
)
.foregroundColor(.blue)
}
}
}
let filteredData = progressData.filter { $0.difficultySystem == selectedSystem }
if !filteredData.isEmpty {
VStack {
// Simple text-based chart placeholder
VStack(alignment: .leading, spacing: 8) {
ForEach(filteredData.indices.prefix(5), id: \.self) { index in
let point = filteredData[index]
HStack {
Text("Session \(index + 1)")
.font(.caption)
.frame(width: 80, alignment: .leading)
Rectangle()
.fill(.blue)
.frame(width: CGFloat(point.maxGradeNumeric * 5), height: 20)
Text(point.maxGrade)
.font(.caption)
.foregroundColor(.blue)
}
}
if filteredData.count > 5 {
Text("... and \(filteredData.count - 5) more sessions")
.font(.caption)
.foregroundColor(.secondary)
}
}
}
.frame(height: 200)
Text(
"X: session number, Y: max \(selectedSystem.displayName.lowercased()) grade achieved"
)
.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) }
guard
let highestGradeProblem = attemptedProblems.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: highestGradeProblem.difficulty.system
)
}
}
}
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: 12) {
Text("Favorite Gym")
.font(.title2)
.fontWeight(.bold)
if let info = favoriteGymInfo {
VStack(alignment: .leading, spacing: 8) {
Text(info.gym.name)
.font(.title3)
.fontWeight(.semibold)
Text("\(info.sessionCount) sessions")
.font(.subheadline)
.foregroundColor(.secondary)
}
} else {
Text("No sessions yet")
.font(.subheadline)
.foregroundColor(.secondary)
}
}
.padding()
.background(
RoundedRectangle(cornerRadius: 16)
.fill(.regularMaterial)
)
}
}
struct RecentActivitySection: View {
@EnvironmentObject var dataManager: ClimbingDataManager
private var recentSessionsCount: Int {
dataManager.sessions.count
}
var body: some View {
VStack(alignment: .leading, spacing: 12) {
Text("Recent Activity")
.font(.title2)
.fontWeight(.bold)
if recentSessionsCount > 0 {
Text("You've had \(recentSessionsCount) sessions")
.font(.subheadline)
} else {
Text("No recent activity")
.font(.subheadline)
.foregroundColor(.secondary)
}
}
.padding()
.background(
RoundedRectangle(cornerRadius: 16)
.fill(.regularMaterial)
)
}
}
struct ProgressDataPoint {
let date: Date
let maxGrade: String
let maxGradeNumeric: Int
let climbType: ClimbType
let difficultySystem: DifficultySystem
}
// MARK: - Helper Functions
func gradeToNumeric(_ system: DifficultySystem, _ grade: String) -> Int {
switch system {
case .vScale:
if grade == "VB" { return 0 }
return Int(grade.replacingOccurrences(of: "V", with: "")) ?? 0
case .font:
let fontMapping: [String: Int] = [
"3": 3, "4A": 4, "4B": 5, "4C": 6, "5A": 7, "5B": 8, "5C": 9,
"6A": 10, "6A+": 11, "6B": 12, "6B+": 13, "6C": 14, "6C+": 15,
"7A": 16, "7A+": 17, "7B": 18, "7B+": 19, "7C": 20, "7C+": 21,
"8A": 22, "8A+": 23, "8B": 24, "8B+": 25, "8C": 26, "8C+": 27,
]
return fontMapping[grade] ?? 0
case .yds:
let ydsMapping: [String: Int] = [
"5.0": 50, "5.1": 51, "5.2": 52, "5.3": 53, "5.4": 54, "5.5": 55,
"5.6": 56, "5.7": 57, "5.8": 58, "5.9": 59, "5.10a": 60, "5.10b": 61,
"5.10c": 62, "5.10d": 63, "5.11a": 64, "5.11b": 65, "5.11c": 66,
"5.11d": 67, "5.12a": 68, "5.12b": 69, "5.12c": 70, "5.12d": 71,
"5.13a": 72, "5.13b": 73, "5.13c": 74, "5.13d": 75, "5.14a": 76,
"5.14b": 77, "5.14c": 78, "5.14d": 79, "5.15a": 80, "5.15b": 81,
"5.15c": 82, "5.15d": 83,
]
return ydsMapping[grade] ?? 0
case .custom:
return Int(grade) ?? 0
}
}
func numericToGrade(_ system: DifficultySystem, _ numeric: Int) -> String {
switch system {
case .vScale:
return numeric == 0 ? "VB" : "V\(numeric)"
case .font:
let fontMapping: [Int: String] = [
3: "3", 4: "4A", 5: "4B", 6: "4C", 7: "5A", 8: "5B", 9: "5C",
10: "6A", 11: "6A+", 12: "6B", 13: "6B+", 14: "6C", 15: "6C+",
16: "7A", 17: "7A+", 18: "7B", 19: "7B+", 20: "7C", 21: "7C+",
22: "8A", 23: "8A+", 24: "8B", 25: "8B+", 26: "8C", 27: "8C+",
]
return fontMapping[numeric] ?? "\(numeric)"
case .yds:
let ydsMapping: [Int: String] = [
50: "5.0", 51: "5.1", 52: "5.2", 53: "5.3", 54: "5.4", 55: "5.5",
56: "5.6", 57: "5.7", 58: "5.8", 59: "5.9", 60: "5.10a", 61: "5.10b",
62: "5.10c", 63: "5.10d", 64: "5.11a", 65: "5.11b", 66: "5.11c",
67: "5.11d", 68: "5.12a", 69: "5.12b", 70: "5.12c", 71: "5.12d",
72: "5.13a", 73: "5.13b", 74: "5.13c", 75: "5.13d", 76: "5.14a",
77: "5.14b", 78: "5.14c", 79: "5.14d", 80: "5.15a", 81: "5.15b",
82: "5.15c", 83: "5.15d",
]
return ydsMapping[numeric] ?? "\(numeric)"
case .custom:
return "\(numeric)"
}
}
#Preview {
AnalyticsView()
.environmentObject(ClimbingDataManager.preview)
}