Import/export fixes, icon, and graphing
This commit is contained in:
Binary file not shown.
@@ -1,35 +1,38 @@
|
|||||||
{
|
{
|
||||||
"images" : [
|
"images": [
|
||||||
{
|
{
|
||||||
"idiom" : "universal",
|
"filename": "app_icon_1024.png",
|
||||||
"platform" : "ios",
|
"idiom": "universal",
|
||||||
"size" : "1024x1024"
|
"platform": "ios",
|
||||||
|
"size": "1024x1024"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"appearances" : [
|
"appearances": [
|
||||||
{
|
{
|
||||||
"appearance" : "luminosity",
|
"appearance": "luminosity",
|
||||||
"value" : "dark"
|
"value": "dark"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"idiom" : "universal",
|
"filename": "app_icon_1024.png",
|
||||||
"platform" : "ios",
|
"idiom": "universal",
|
||||||
"size" : "1024x1024"
|
"platform": "ios",
|
||||||
|
"size": "1024x1024"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"appearances" : [
|
"appearances": [
|
||||||
{
|
{
|
||||||
"appearance" : "luminosity",
|
"appearance": "luminosity",
|
||||||
"value" : "tinted"
|
"value": "tinted"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"idiom" : "universal",
|
"filename": "app_icon_1024.png",
|
||||||
"platform" : "ios",
|
"idiom": "universal",
|
||||||
"size" : "1024x1024"
|
"platform": "ios",
|
||||||
|
"size": "1024x1024"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"info" : {
|
"info": {
|
||||||
"author" : "xcode",
|
"author": "xcode",
|
||||||
"version" : 1
|
"version": 1
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Binary file not shown.
|
After Width: | Height: | Size: 22 KiB |
23
ios/OpenClimb/Assets.xcassets/MountainsIcon.imageset/Contents.json
vendored
Normal file
23
ios/OpenClimb/Assets.xcassets/MountainsIcon.imageset/Contents.json
vendored
Normal file
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
BIN
ios/OpenClimb/Assets.xcassets/MountainsIcon.imageset/mountains_icon_256.png
vendored
Normal file
BIN
ios/OpenClimb/Assets.xcassets/MountainsIcon.imageset/mountains_icon_256.png
vendored
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 5.3 KiB |
@@ -429,9 +429,6 @@ struct ZipUtils {
|
|||||||
|
|
||||||
let endRecord = data.subdata(in: endOfCentralDirOffset..<endOfCentralDirOffset + 22)
|
let endRecord = data.subdata(in: endOfCentralDirOffset..<endOfCentralDirOffset + 22)
|
||||||
let numEntries = endRecord.subdata(in: 8..<10).withUnsafeBytes { $0.load(as: UInt16.self) }
|
let numEntries = endRecord.subdata(in: 8..<10).withUnsafeBytes { $0.load(as: UInt16.self) }
|
||||||
let centralDirSize = endRecord.subdata(in: 12..<16).withUnsafeBytes {
|
|
||||||
$0.load(as: UInt32.self)
|
|
||||||
}
|
|
||||||
let centralDirOffset = endRecord.subdata(in: 16..<20).withUnsafeBytes {
|
let centralDirOffset = endRecord.subdata(in: 16..<20).withUnsafeBytes {
|
||||||
$0.load(as: UInt32.self)
|
$0.load(as: UInt32.self)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -76,10 +76,10 @@ struct AddAttemptView: View {
|
|||||||
.onAppear {
|
.onAppear {
|
||||||
setupInitialValues()
|
setupInitialValues()
|
||||||
}
|
}
|
||||||
.onChange(of: selectedClimbType) { _ in
|
.onChange(of: selectedClimbType) {
|
||||||
updateDifficultySystem()
|
updateDifficultySystem()
|
||||||
}
|
}
|
||||||
.onChange(of: selectedDifficultySystem) { _ in
|
.onChange(of: selectedDifficultySystem) {
|
||||||
resetGradeIfNeeded()
|
resetGradeIfNeeded()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -182,8 +182,12 @@ struct AddAttemptView: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if selectedDifficultySystem == .custom {
|
if selectedDifficultySystem == .custom {
|
||||||
TextField("Grade (Required)", text: $newProblemGrade)
|
TextField("Grade (Required - numbers only)", text: $newProblemGrade)
|
||||||
.keyboardType(.numberPad)
|
.keyboardType(.numberPad)
|
||||||
|
.onChange(of: newProblemGrade) {
|
||||||
|
// Filter out non-numeric characters
|
||||||
|
newProblemGrade = newProblemGrade.filter { $0.isNumber }
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
VStack(alignment: .leading, spacing: 8) {
|
VStack(alignment: .leading, spacing: 8) {
|
||||||
Text("Grade (Required)")
|
Text("Grade (Required)")
|
||||||
@@ -526,7 +530,7 @@ struct EditAttemptView: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private func updateAttempt() {
|
private func updateAttempt() {
|
||||||
guard let problem = selectedProblem else { return }
|
guard selectedProblem != nil else { return }
|
||||||
|
|
||||||
let updatedAttempt = attempt.updated(
|
let updatedAttempt = attempt.updated(
|
||||||
result: selectedResult,
|
result: selectedResult,
|
||||||
|
|||||||
@@ -67,7 +67,7 @@ struct AddEditGymView: View {
|
|||||||
.onAppear {
|
.onAppear {
|
||||||
loadExistingGym()
|
loadExistingGym()
|
||||||
}
|
}
|
||||||
.onChange(of: selectedClimbTypes) { _ in
|
.onChange(of: selectedClimbTypes) {
|
||||||
updateAvailableDifficultySystems()
|
updateAvailableDifficultySystems()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -95,16 +95,16 @@ struct AddEditProblemView: View {
|
|||||||
loadExistingProblem()
|
loadExistingProblem()
|
||||||
setupInitialGym()
|
setupInitialGym()
|
||||||
}
|
}
|
||||||
.onChange(of: selectedGym) { _ in
|
.onChange(of: selectedGym) {
|
||||||
updateAvailableOptions()
|
updateAvailableOptions()
|
||||||
}
|
}
|
||||||
.onChange(of: selectedClimbType) { _ in
|
.onChange(of: selectedClimbType) {
|
||||||
updateDifficultySystem()
|
updateDifficultySystem()
|
||||||
}
|
}
|
||||||
.onChange(of: selectedDifficultySystem) { _ in
|
.onChange(of: selectedDifficultySystem) {
|
||||||
resetGradeIfNeeded()
|
resetGradeIfNeeded()
|
||||||
}
|
}
|
||||||
.onChange(of: selectedPhotos) { _ in
|
.onChange(of: selectedPhotos) {
|
||||||
Task {
|
Task {
|
||||||
await loadSelectedPhotos()
|
await loadSelectedPhotos()
|
||||||
}
|
}
|
||||||
@@ -171,7 +171,7 @@ struct AddEditProblemView: View {
|
|||||||
|
|
||||||
@ViewBuilder
|
@ViewBuilder
|
||||||
private func ClimbTypeSection() -> some View {
|
private func ClimbTypeSection() -> some View {
|
||||||
if let gym = selectedGym {
|
if selectedGym != nil {
|
||||||
Section("Climb Type") {
|
Section("Climb Type") {
|
||||||
ForEach(availableClimbTypes, id: \.self) { climbType in
|
ForEach(availableClimbTypes, id: \.self) { climbType in
|
||||||
HStack {
|
HStack {
|
||||||
@@ -227,8 +227,13 @@ struct AddEditProblemView: View {
|
|||||||
.font(.headline)
|
.font(.headline)
|
||||||
|
|
||||||
if selectedDifficultySystem == .custom || availableGrades.isEmpty {
|
if selectedDifficultySystem == .custom || availableGrades.isEmpty {
|
||||||
TextField("Enter custom grade", text: $difficultyGrade)
|
TextField("Enter custom grade (numbers only)", text: $difficultyGrade)
|
||||||
.textFieldStyle(.roundedBorder)
|
.textFieldStyle(.roundedBorder)
|
||||||
|
.keyboardType(.numberPad)
|
||||||
|
.onChange(of: difficultyGrade) {
|
||||||
|
// Filter out non-numeric characters
|
||||||
|
difficultyGrade = difficultyGrade.filter { $0.isNumber }
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
Menu {
|
Menu {
|
||||||
if !difficultyGrade.isEmpty {
|
if !difficultyGrade.isEmpty {
|
||||||
|
|||||||
@@ -20,9 +20,11 @@ struct AnalyticsView: View {
|
|||||||
|
|
||||||
ProgressChartSection()
|
ProgressChartSection()
|
||||||
|
|
||||||
FavoriteGymSection()
|
HStack(spacing: 16) {
|
||||||
|
FavoriteGymSection()
|
||||||
|
|
||||||
RecentActivitySection()
|
RecentActivitySection()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
.padding()
|
.padding()
|
||||||
}
|
}
|
||||||
@@ -34,9 +36,9 @@ struct AnalyticsView: View {
|
|||||||
struct HeaderSection: View {
|
struct HeaderSection: View {
|
||||||
var body: some View {
|
var body: some View {
|
||||||
HStack {
|
HStack {
|
||||||
Image(systemName: "mountain.2.fill")
|
Image("MountainsIcon")
|
||||||
.font(.title)
|
.resizable()
|
||||||
.foregroundColor(.blue)
|
.frame(width: 32, height: 32)
|
||||||
|
|
||||||
Text("Analytics")
|
Text("Analytics")
|
||||||
.font(.title)
|
.font(.title)
|
||||||
@@ -133,7 +135,13 @@ struct ProgressChartSection: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private var usedSystems: [DifficultySystem] {
|
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 {
|
var body: some View {
|
||||||
@@ -148,20 +156,35 @@ struct ProgressChartSection: View {
|
|||||||
if usedSystems.count > 1 {
|
if usedSystems.count > 1 {
|
||||||
Menu {
|
Menu {
|
||||||
ForEach(usedSystems, id: \.self) { system in
|
ForEach(usedSystems, id: \.self) { system in
|
||||||
Button(system.displayName) {
|
Button(action: {
|
||||||
selectedSystem = system
|
selectedSystem = system
|
||||||
|
}) {
|
||||||
|
HStack {
|
||||||
|
Text(system.displayName)
|
||||||
|
if selectedSystem == system {
|
||||||
|
Spacer()
|
||||||
|
Image(systemName: "checkmark")
|
||||||
|
.foregroundColor(.blue)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} label: {
|
} label: {
|
||||||
Text(selectedSystem.displayName)
|
HStack(spacing: 4) {
|
||||||
.font(.caption)
|
Text(selectedSystem.displayName)
|
||||||
.padding(.horizontal, 8)
|
.font(.subheadline)
|
||||||
.padding(.vertical, 4)
|
.fontWeight(.medium)
|
||||||
.background(
|
Image(systemName: "chevron.down")
|
||||||
RoundedRectangle(cornerRadius: 8)
|
.font(.caption)
|
||||||
.fill(.blue.opacity(0.1))
|
}
|
||||||
)
|
.padding(.horizontal, 12)
|
||||||
.foregroundColor(.blue)
|
.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 }
|
let filteredData = progressData.filter { $0.difficultySystem == selectedSystem }
|
||||||
|
|
||||||
if !filteredData.isEmpty {
|
if !filteredData.isEmpty {
|
||||||
VStack {
|
LineChartView(data: filteredData, selectedSystem: selectedSystem)
|
||||||
// Simple text-based chart placeholder
|
.frame(height: 200)
|
||||||
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(
|
Text(
|
||||||
"X: session number, Y: max \(selectedSystem.displayName.lowercased()) grade achieved"
|
"Progress: max \(selectedSystem.displayName.lowercased()) grade achieved per session"
|
||||||
)
|
)
|
||||||
.font(.caption)
|
.font(.caption)
|
||||||
.foregroundColor(.secondary)
|
.foregroundColor(.secondary)
|
||||||
@@ -239,22 +236,28 @@ struct ProgressChartSection: View {
|
|||||||
let attemptedProblemIds = sessionAttempts.map { $0.problemId }
|
let attemptedProblemIds = sessionAttempts.map { $0.problemId }
|
||||||
let attemptedProblems = problems.filter { attemptedProblemIds.contains($0.id) }
|
let attemptedProblems = problems.filter { attemptedProblemIds.contains($0.id) }
|
||||||
|
|
||||||
guard
|
// Group problems by difficulty system
|
||||||
let highestGradeProblem = attemptedProblems.max(by: {
|
let problemsBySystem = Dictionary(grouping: attemptedProblems) { $0.difficulty.system }
|
||||||
$0.difficulty.numericValue < $1.difficulty.numericValue
|
|
||||||
})
|
|
||||||
else {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
return ProgressDataPoint(
|
// Create data points for each system used in this session
|
||||||
date: session.date,
|
return problemsBySystem.compactMap { (system, systemProblems) -> ProgressDataPoint? in
|
||||||
maxGrade: highestGradeProblem.difficulty.grade,
|
guard
|
||||||
maxGradeNumeric: highestGradeProblem.difficulty.numericValue,
|
let highestGradeProblem = systemProblems.max(by: {
|
||||||
climbType: highestGradeProblem.climbType,
|
$0.difficulty.numericValue < $1.difficulty.numericValue
|
||||||
difficultySystem: highestGradeProblem.difficulty.system
|
})
|
||||||
)
|
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 {
|
var body: some View {
|
||||||
VStack(alignment: .leading, spacing: 12) {
|
VStack(alignment: .leading, spacing: 16) {
|
||||||
Text("Favorite Gym")
|
HStack {
|
||||||
.font(.title2)
|
Image(systemName: "location.fill")
|
||||||
.fontWeight(.bold)
|
.font(.title2)
|
||||||
|
.foregroundColor(.purple)
|
||||||
|
|
||||||
|
Text("Favorite Gym")
|
||||||
|
.font(.title2)
|
||||||
|
.fontWeight(.bold)
|
||||||
|
|
||||||
|
Spacer()
|
||||||
|
}
|
||||||
|
|
||||||
if let info = favoriteGymInfo {
|
if let info = favoriteGymInfo {
|
||||||
VStack(alignment: .leading, spacing: 8) {
|
VStack(alignment: .leading, spacing: 12) {
|
||||||
Text(info.gym.name)
|
Text(info.gym.name)
|
||||||
.font(.title3)
|
.font(.title3)
|
||||||
.fontWeight(.semibold)
|
.fontWeight(.semibold)
|
||||||
|
.foregroundColor(.primary)
|
||||||
|
|
||||||
Text("\(info.sessionCount) sessions")
|
HStack {
|
||||||
.font(.subheadline)
|
Image(systemName: "calendar")
|
||||||
.foregroundColor(.secondary)
|
.font(.subheadline)
|
||||||
|
.foregroundColor(.purple)
|
||||||
|
|
||||||
|
Text("\(info.sessionCount) sessions")
|
||||||
|
.font(.subheadline)
|
||||||
|
.foregroundColor(.secondary)
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer()
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
Text("No sessions yet")
|
VStack(alignment: .leading, spacing: 8) {
|
||||||
.font(.subheadline)
|
Text("No sessions yet")
|
||||||
.foregroundColor(.secondary)
|
.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()
|
.padding()
|
||||||
.background(
|
.background(
|
||||||
RoundedRectangle(cornerRadius: 16)
|
RoundedRectangle(cornerRadius: 16)
|
||||||
@@ -311,21 +340,63 @@ struct RecentActivitySection: View {
|
|||||||
dataManager.sessions.count
|
dataManager.sessions.count
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private var totalAttempts: Int {
|
||||||
|
dataManager.attempts.count
|
||||||
|
}
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
VStack(alignment: .leading, spacing: 12) {
|
VStack(alignment: .leading, spacing: 16) {
|
||||||
Text("Recent Activity")
|
HStack {
|
||||||
.font(.title2)
|
Image(systemName: "clock.fill")
|
||||||
.fontWeight(.bold)
|
.font(.title2)
|
||||||
|
.foregroundColor(.blue)
|
||||||
|
|
||||||
|
Text("Recent Activity")
|
||||||
|
.font(.title2)
|
||||||
|
.fontWeight(.bold)
|
||||||
|
|
||||||
|
Spacer()
|
||||||
|
}
|
||||||
|
|
||||||
if recentSessionsCount > 0 {
|
if recentSessionsCount > 0 {
|
||||||
Text("You've had \(recentSessionsCount) sessions")
|
VStack(alignment: .leading, spacing: 12) {
|
||||||
.font(.subheadline)
|
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 {
|
} else {
|
||||||
Text("No recent activity")
|
VStack(alignment: .leading, spacing: 8) {
|
||||||
.font(.subheadline)
|
Text("No recent activity")
|
||||||
.foregroundColor(.secondary)
|
.font(.subheadline)
|
||||||
|
.foregroundColor(.secondary)
|
||||||
|
|
||||||
|
Text("Start your first session!")
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundColor(.secondary)
|
||||||
|
|
||||||
|
Spacer()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
.frame(maxWidth: .infinity, minHeight: 120, alignment: .topLeading)
|
||||||
.padding()
|
.padding()
|
||||||
.background(
|
.background(
|
||||||
RoundedRectangle(cornerRadius: 16)
|
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..<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 {
|
struct ProgressDataPoint {
|
||||||
let date: Date
|
let date: Date
|
||||||
let maxGrade: String
|
let maxGrade: String
|
||||||
@@ -344,63 +540,6 @@ struct ProgressDataPoint {
|
|||||||
|
|
||||||
// MARK: - Helper Functions
|
// 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 {
|
#Preview {
|
||||||
AnalyticsView()
|
AnalyticsView()
|
||||||
.environmentObject(ClimbingDataManager.preview)
|
.environmentObject(ClimbingDataManager.preview)
|
||||||
|
|||||||
@@ -8,31 +8,53 @@
|
|||||||
import SwiftUI
|
import SwiftUI
|
||||||
import UniformTypeIdentifiers
|
import UniformTypeIdentifiers
|
||||||
|
|
||||||
|
enum SheetType {
|
||||||
|
case export(Data)
|
||||||
|
case importData
|
||||||
|
}
|
||||||
|
|
||||||
struct SettingsView: View {
|
struct SettingsView: View {
|
||||||
@EnvironmentObject var dataManager: ClimbingDataManager
|
@EnvironmentObject var dataManager: ClimbingDataManager
|
||||||
@State private var showingResetAlert = false
|
@State private var activeSheet: SheetType?
|
||||||
@State private var showingExportSheet = false
|
|
||||||
@State private var showingImportSheet = false
|
|
||||||
@State private var exportData: Data?
|
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
NavigationView {
|
List {
|
||||||
List {
|
DataManagementSection(
|
||||||
DataManagementSection()
|
activeSheet: $activeSheet
|
||||||
|
)
|
||||||
|
|
||||||
AppInfoSection()
|
AppInfoSection()
|
||||||
|
}
|
||||||
|
.navigationTitle("Settings")
|
||||||
|
.sheet(
|
||||||
|
item: Binding<SheetType?>(
|
||||||
|
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 {
|
struct DataManagementSection: View {
|
||||||
@EnvironmentObject var dataManager: ClimbingDataManager
|
@EnvironmentObject var dataManager: ClimbingDataManager
|
||||||
|
@Binding var activeSheet: SheetType?
|
||||||
@State private var showingResetAlert = false
|
@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
|
@State private var isExporting = false
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
@@ -60,7 +82,7 @@ struct DataManagementSection: View {
|
|||||||
|
|
||||||
// Import Data
|
// Import Data
|
||||||
Button(action: {
|
Button(action: {
|
||||||
showingImportSheet = true
|
activeSheet = .importData
|
||||||
}) {
|
}) {
|
||||||
HStack {
|
HStack {
|
||||||
Image(systemName: "square.and.arrow.down")
|
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."
|
"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() {
|
private func exportDataAsync() {
|
||||||
isExporting = true
|
isExporting = true
|
||||||
|
|
||||||
Task {
|
Task {
|
||||||
let data = await withCheckedContinuation { continuation in
|
let data = await MainActor.run { dataManager.exportData() }
|
||||||
DispatchQueue.global(qos: .userInitiated).async {
|
isExporting = false
|
||||||
let result = dataManager.exportData()
|
if let data = data {
|
||||||
continuation.resume(returning: result)
|
activeSheet = .export(data)
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
await MainActor.run {
|
|
||||||
isExporting = false
|
|
||||||
if let data = data {
|
|
||||||
exportData = data
|
|
||||||
showingExportSheet = true
|
|
||||||
} else {
|
|
||||||
// Error message should already be set by dataManager
|
|
||||||
exportData = nil
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -143,8 +142,9 @@ struct AppInfoSection: View {
|
|||||||
var body: some View {
|
var body: some View {
|
||||||
Section("App Information") {
|
Section("App Information") {
|
||||||
HStack {
|
HStack {
|
||||||
Image(systemName: "mountain.2.fill")
|
Image("MountainsIcon")
|
||||||
.foregroundColor(.blue)
|
.resizable()
|
||||||
|
.frame(width: 24, height: 24)
|
||||||
VStack(alignment: .leading) {
|
VStack(alignment: .leading) {
|
||||||
Text("OpenClimb")
|
Text("OpenClimb")
|
||||||
.font(.headline)
|
.font(.headline)
|
||||||
@@ -163,15 +163,6 @@ struct AppInfoSection: View {
|
|||||||
Text("\(appVersion) (\(buildNumber))")
|
Text("\(appVersion) (\(buildNumber))")
|
||||||
.foregroundColor(.secondary)
|
.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,
|
item: fileURL,
|
||||||
preview: SharePreview(
|
preview: SharePreview(
|
||||||
"OpenClimb Data Export",
|
"OpenClimb Data Export",
|
||||||
image: Image(systemName: "mountain.2.fill"))
|
image: Image("MountainsIcon"))
|
||||||
) {
|
) {
|
||||||
Label("Share Data", systemImage: "square.and.arrow.up")
|
Label("Share Data", systemImage: "square.and.arrow.up")
|
||||||
.font(.headline)
|
.font(.headline)
|
||||||
@@ -324,13 +315,6 @@ struct ImportDataView: View {
|
|||||||
Text("Import climbing data from a previously exported ZIP file.")
|
Text("Import climbing data from a previously exported ZIP file.")
|
||||||
.multilineTextAlignment(.center)
|
.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!")
|
Text("⚠️ Warning: This will replace all current data!")
|
||||||
.font(.subheadline)
|
.font(.subheadline)
|
||||||
.foregroundColor(.red)
|
.foregroundColor(.red)
|
||||||
@@ -423,7 +407,10 @@ struct ImportDataView: View {
|
|||||||
|
|
||||||
await MainActor.run {
|
await MainActor.run {
|
||||||
isImporting = false
|
isImporting = false
|
||||||
dismiss()
|
// Auto-close after successful import
|
||||||
|
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) {
|
||||||
|
dismiss()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
await MainActor.run {
|
await MainActor.run {
|
||||||
|
|||||||
Reference in New Issue
Block a user