diff --git a/ios/OpenClimb.xcodeproj/project.xcworkspace/xcuserdata/atridad.xcuserdatad/UserInterfaceState.xcuserstate b/ios/OpenClimb.xcodeproj/project.xcworkspace/xcuserdata/atridad.xcuserdatad/UserInterfaceState.xcuserstate index 9e7c81b..d9d3b26 100644 Binary files a/ios/OpenClimb.xcodeproj/project.xcworkspace/xcuserdata/atridad.xcuserdatad/UserInterfaceState.xcuserstate and b/ios/OpenClimb.xcodeproj/project.xcworkspace/xcuserdata/atridad.xcuserdatad/UserInterfaceState.xcuserstate differ diff --git a/ios/OpenClimb/Assets.xcassets/AppIcon.appiconset/Contents.json b/ios/OpenClimb/Assets.xcassets/AppIcon.appiconset/Contents.json index 2305880..866ce16 100644 --- a/ios/OpenClimb/Assets.xcassets/AppIcon.appiconset/Contents.json +++ b/ios/OpenClimb/Assets.xcassets/AppIcon.appiconset/Contents.json @@ -1,35 +1,38 @@ { - "images" : [ + "images": [ { - "idiom" : "universal", - "platform" : "ios", - "size" : "1024x1024" + "filename": "app_icon_1024.png", + "idiom": "universal", + "platform": "ios", + "size": "1024x1024" }, { - "appearances" : [ + "appearances": [ { - "appearance" : "luminosity", - "value" : "dark" + "appearance": "luminosity", + "value": "dark" } ], - "idiom" : "universal", - "platform" : "ios", - "size" : "1024x1024" + "filename": "app_icon_1024.png", + "idiom": "universal", + "platform": "ios", + "size": "1024x1024" }, { - "appearances" : [ + "appearances": [ { - "appearance" : "luminosity", - "value" : "tinted" + "appearance": "luminosity", + "value": "tinted" } ], - "idiom" : "universal", - "platform" : "ios", - "size" : "1024x1024" + "filename": "app_icon_1024.png", + "idiom": "universal", + "platform": "ios", + "size": "1024x1024" } ], - "info" : { - "author" : "xcode", - "version" : 1 + "info": { + "author": "xcode", + "version": 1 } } diff --git a/ios/OpenClimb/Assets.xcassets/AppIcon.appiconset/app_icon_1024.png b/ios/OpenClimb/Assets.xcassets/AppIcon.appiconset/app_icon_1024.png new file mode 100644 index 0000000..1b6fdd3 Binary files /dev/null and b/ios/OpenClimb/Assets.xcassets/AppIcon.appiconset/app_icon_1024.png differ diff --git a/ios/OpenClimb/Assets.xcassets/MountainsIcon.imageset/Contents.json b/ios/OpenClimb/Assets.xcassets/MountainsIcon.imageset/Contents.json new file mode 100644 index 0000000..75a2160 --- /dev/null +++ b/ios/OpenClimb/Assets.xcassets/MountainsIcon.imageset/Contents.json @@ -0,0 +1,23 @@ +{ + "images": [ + { + "filename": "mountains_icon_256.png", + "idiom": "universal", + "scale": "1x" + }, + { + "filename": "mountains_icon_256.png", + "idiom": "universal", + "scale": "2x" + }, + { + "filename": "mountains_icon_256.png", + "idiom": "universal", + "scale": "3x" + } + ], + "info": { + "author": "xcode", + "version": 1 + } +} diff --git a/ios/OpenClimb/Assets.xcassets/MountainsIcon.imageset/mountains_icon_256.png b/ios/OpenClimb/Assets.xcassets/MountainsIcon.imageset/mountains_icon_256.png new file mode 100644 index 0000000..209dd1c Binary files /dev/null and b/ios/OpenClimb/Assets.xcassets/MountainsIcon.imageset/mountains_icon_256.png differ diff --git a/ios/OpenClimb/Utils/ZipUtils.swift b/ios/OpenClimb/Utils/ZipUtils.swift index ecd7314..cec0894 100644 --- a/ios/OpenClimb/Utils/ZipUtils.swift +++ b/ios/OpenClimb/Utils/ZipUtils.swift @@ -429,9 +429,6 @@ struct ZipUtils { let endRecord = data.subdata(in: endOfCentralDirOffset.. some View { - if let gym = selectedGym { + if selectedGym != nil { Section("Climb Type") { ForEach(availableClimbTypes, id: \.self) { climbType in HStack { @@ -227,8 +227,13 @@ struct AddEditProblemView: View { .font(.headline) if selectedDifficultySystem == .custom || availableGrades.isEmpty { - TextField("Enter custom grade", text: $difficultyGrade) + TextField("Enter custom grade (numbers only)", text: $difficultyGrade) .textFieldStyle(.roundedBorder) + .keyboardType(.numberPad) + .onChange(of: difficultyGrade) { + // Filter out non-numeric characters + difficultyGrade = difficultyGrade.filter { $0.isNumber } + } } else { Menu { if !difficultyGrade.isEmpty { diff --git a/ios/OpenClimb/Views/AnalyticsView.swift b/ios/OpenClimb/Views/AnalyticsView.swift index 7a767b9..59e031e 100644 --- a/ios/OpenClimb/Views/AnalyticsView.swift +++ b/ios/OpenClimb/Views/AnalyticsView.swift @@ -20,9 +20,11 @@ struct AnalyticsView: View { ProgressChartSection() - FavoriteGymSection() + HStack(spacing: 16) { + FavoriteGymSection() - RecentActivitySection() + RecentActivitySection() + } } .padding() } @@ -34,9 +36,9 @@ struct AnalyticsView: View { struct HeaderSection: View { var body: some View { HStack { - Image(systemName: "mountain.2.fill") - .font(.title) - .foregroundColor(.blue) + Image("MountainsIcon") + .resizable() + .frame(width: 32, height: 32) Text("Analytics") .font(.title) @@ -133,7 +135,13 @@ struct ProgressChartSection: View { } private var usedSystems: [DifficultySystem] { - Set(progressData.map { $0.difficultySystem }).sorted { $0.rawValue < $1.rawValue } + 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 { @@ -148,20 +156,35 @@ struct ProgressChartSection: View { if usedSystems.count > 1 { Menu { ForEach(usedSystems, id: \.self) { system in - Button(system.displayName) { + Button(action: { selectedSystem = system + }) { + HStack { + Text(system.displayName) + if selectedSystem == system { + Spacer() + Image(systemName: "checkmark") + .foregroundColor(.blue) + } + } } } } label: { - Text(selectedSystem.displayName) - .font(.caption) - .padding(.horizontal, 8) - .padding(.vertical, 4) - .background( - RoundedRectangle(cornerRadius: 8) - .fill(.blue.opacity(0.1)) - ) - .foregroundColor(.blue) + 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) } } } @@ -169,37 +192,11 @@ struct ProgressChartSection: View { 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) + LineChartView(data: filteredData, selectedSystem: selectedSystem) + .frame(height: 200) Text( - "X: session number, Y: max \(selectedSystem.displayName.lowercased()) grade achieved" + "Progress: max \(selectedSystem.displayName.lowercased()) grade achieved per session" ) .font(.caption) .foregroundColor(.secondary) @@ -239,22 +236,28 @@ struct ProgressChartSection: View { 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 - } + // Group problems by difficulty system + let problemsBySystem = Dictionary(grouping: attemptedProblems) { $0.difficulty.system } - return ProgressDataPoint( - date: session.date, - maxGrade: highestGradeProblem.difficulty.grade, - maxGradeNumeric: highestGradeProblem.difficulty.numericValue, - climbType: highestGradeProblem.climbType, - difficultySystem: highestGradeProblem.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 } } } @@ -275,27 +278,53 @@ struct FavoriteGymSection: View { } var body: some View { - VStack(alignment: .leading, spacing: 12) { - Text("Favorite Gym") - .font(.title2) - .fontWeight(.bold) + 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: 8) { + VStack(alignment: .leading, spacing: 12) { Text(info.gym.name) .font(.title3) .fontWeight(.semibold) + .foregroundColor(.primary) - Text("\(info.sessionCount) sessions") - .font(.subheadline) - .foregroundColor(.secondary) + HStack { + Image(systemName: "calendar") + .font(.subheadline) + .foregroundColor(.purple) + + Text("\(info.sessionCount) sessions") + .font(.subheadline) + .foregroundColor(.secondary) + } + + Spacer() } } else { - Text("No sessions yet") - .font(.subheadline) - .foregroundColor(.secondary) + 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) @@ -311,21 +340,63 @@ struct RecentActivitySection: View { dataManager.sessions.count } + private var totalAttempts: Int { + dataManager.attempts.count + } + var body: some View { - VStack(alignment: .leading, spacing: 12) { - Text("Recent Activity") - .font(.title2) - .fontWeight(.bold) + 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 { - Text("You've had \(recentSessionsCount) sessions") - .font(.subheadline) + 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 { - Text("No recent activity") - .font(.subheadline) - .foregroundColor(.secondary) + 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) @@ -334,6 +405,131 @@ struct RecentActivitySection: View { } } +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.. 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 @@ -344,63 +540,6 @@ struct ProgressDataPoint { // 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) diff --git a/ios/OpenClimb/Views/SettingsView.swift b/ios/OpenClimb/Views/SettingsView.swift index 91866fe..018e9e3 100644 --- a/ios/OpenClimb/Views/SettingsView.swift +++ b/ios/OpenClimb/Views/SettingsView.swift @@ -8,31 +8,53 @@ import SwiftUI import UniformTypeIdentifiers +enum SheetType { + case export(Data) + case importData +} + struct SettingsView: View { @EnvironmentObject var dataManager: ClimbingDataManager - @State private var showingResetAlert = false - @State private var showingExportSheet = false - @State private var showingImportSheet = false - @State private var exportData: Data? + @State private var activeSheet: SheetType? var body: some View { - NavigationView { - List { - DataManagementSection() + List { + DataManagementSection( + activeSheet: $activeSheet + ) - AppInfoSection() + AppInfoSection() + } + .navigationTitle("Settings") + .sheet( + item: Binding( + get: { activeSheet }, + set: { activeSheet = $0 } + ) + ) { sheetType in + switch sheetType { + case .export(let data): + ExportDataView(data: data) + case .importData: + ImportDataView() } - .navigationTitle("Settings") + } + } +} + +extension SheetType: Identifiable { + var id: String { + switch self { + case .export: return "export" + case .importData: return "import" } } } struct DataManagementSection: View { @EnvironmentObject var dataManager: ClimbingDataManager + @Binding var activeSheet: SheetType? @State private var showingResetAlert = false - @State private var showingExportSheet = false - @State private var showingImportSheet = false - @State private var exportData: Data? @State private var isExporting = false var body: some View { @@ -60,7 +82,7 @@ struct DataManagementSection: View { // Import Data Button(action: { - showingImportSheet = true + activeSheet = .importData }) { HStack { Image(systemName: "square.and.arrow.down") @@ -94,38 +116,15 @@ struct DataManagementSection: View { "Are you sure you want to reset all data? This will permanently delete:\n\n• All gyms and their information\n• All problems and their images\n• All climbing sessions\n• All attempts and progress data\n\nThis action cannot be undone. Consider exporting your data first." ) } - .sheet(isPresented: $showingExportSheet) { - if let data = exportData { - ExportDataView(data: data) - } else { - Text("No export data available") - } - } - .sheet(isPresented: $showingImportSheet) { - ImportDataView() - } } private func exportDataAsync() { isExporting = true - Task { - let data = await withCheckedContinuation { continuation in - DispatchQueue.global(qos: .userInitiated).async { - let result = dataManager.exportData() - continuation.resume(returning: result) - } - } - - await MainActor.run { - isExporting = false - if let data = data { - exportData = data - showingExportSheet = true - } else { - // Error message should already be set by dataManager - exportData = nil - } + let data = await MainActor.run { dataManager.exportData() } + isExporting = false + if let data = data { + activeSheet = .export(data) } } } @@ -143,8 +142,9 @@ struct AppInfoSection: View { var body: some View { Section("App Information") { HStack { - Image(systemName: "mountain.2.fill") - .foregroundColor(.blue) + Image("MountainsIcon") + .resizable() + .frame(width: 24, height: 24) VStack(alignment: .leading) { Text("OpenClimb") .font(.headline) @@ -163,15 +163,6 @@ struct AppInfoSection: View { Text("\(appVersion) (\(buildNumber))") .foregroundColor(.secondary) } - - HStack { - Image(systemName: "person.fill") - .foregroundColor(.blue) - Text("Developer") - Spacer() - Text("OpenClimb Team") - .foregroundColor(.secondary) - } } } } @@ -203,7 +194,7 @@ struct ExportDataView: View { item: fileURL, preview: SharePreview( "OpenClimb Data Export", - image: Image(systemName: "mountain.2.fill")) + image: Image("MountainsIcon")) ) { Label("Share Data", systemImage: "square.and.arrow.up") .font(.headline) @@ -324,13 +315,6 @@ struct ImportDataView: View { Text("Import climbing data from a previously exported ZIP file.") .multilineTextAlignment(.center) - Text( - "Fully compatible with Android exports - identical ZIP format with images." - ) - .font(.subheadline) - .foregroundColor(.blue) - .multilineTextAlignment(.center) - Text("⚠️ Warning: This will replace all current data!") .font(.subheadline) .foregroundColor(.red) @@ -423,7 +407,10 @@ struct ImportDataView: View { await MainActor.run { isImporting = false - dismiss() + // Auto-close after successful import + DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { + dismiss() + } } } catch { await MainActor.run {