diff --git a/.idea/.gitignore b/.idea/.gitignore
deleted file mode 100644
index 26d3352..0000000
--- a/.idea/.gitignore
+++ /dev/null
@@ -1,3 +0,0 @@
-# Default ignored files
-/shelf/
-/workspace.xml
diff --git a/.idea/AndroidProjectSystem.xml b/.idea/AndroidProjectSystem.xml
deleted file mode 100644
index 4a53bee..0000000
--- a/.idea/AndroidProjectSystem.xml
+++ /dev/null
@@ -1,6 +0,0 @@
-
-
-
-
-
-
\ No newline at end of file
diff --git a/.idea/caches/deviceStreaming.xml b/.idea/caches/deviceStreaming.xml
deleted file mode 100644
index 17b82fc..0000000
--- a/.idea/caches/deviceStreaming.xml
+++ /dev/null
@@ -1,860 +0,0 @@
-
-
-
-
-
-
\ No newline at end of file
diff --git a/.idea/deploymentTargetSelector.xml b/.idea/deploymentTargetSelector.xml
deleted file mode 100644
index f185138..0000000
--- a/.idea/deploymentTargetSelector.xml
+++ /dev/null
@@ -1,18 +0,0 @@
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
\ No newline at end of file
diff --git a/.idea/gradle.xml b/.idea/gradle.xml
deleted file mode 100644
index 2504dc6..0000000
--- a/.idea/gradle.xml
+++ /dev/null
@@ -1,19 +0,0 @@
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
\ No newline at end of file
diff --git a/.idea/inspectionProfiles/Project_Default.xml b/.idea/inspectionProfiles/Project_Default.xml
deleted file mode 100644
index f0c6ad0..0000000
--- a/.idea/inspectionProfiles/Project_Default.xml
+++ /dev/null
@@ -1,50 +0,0 @@
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
\ No newline at end of file
diff --git a/.idea/kotlinc.xml b/.idea/kotlinc.xml
deleted file mode 100644
index c224ad5..0000000
--- a/.idea/kotlinc.xml
+++ /dev/null
@@ -1,6 +0,0 @@
-
-
-
-
-
-
\ No newline at end of file
diff --git a/.idea/migrations.xml b/.idea/migrations.xml
deleted file mode 100644
index f8051a6..0000000
--- a/.idea/migrations.xml
+++ /dev/null
@@ -1,10 +0,0 @@
-
-
-
-
-
-
-
-
-
-
\ No newline at end of file
diff --git a/.idea/misc.xml b/.idea/misc.xml
deleted file mode 100644
index 6a1c546..0000000
--- a/.idea/misc.xml
+++ /dev/null
@@ -1,4 +0,0 @@
-
-
-
-
\ No newline at end of file
diff --git a/.idea/runConfigurations.xml b/.idea/runConfigurations.xml
deleted file mode 100644
index 16660f1..0000000
--- a/.idea/runConfigurations.xml
+++ /dev/null
@@ -1,17 +0,0 @@
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
\ No newline at end of file
diff --git a/.idea/vcs.xml b/.idea/vcs.xml
deleted file mode 100644
index 35eb1dd..0000000
--- a/.idea/vcs.xml
+++ /dev/null
@@ -1,6 +0,0 @@
-
-
-
-
-
-
\ No newline at end of file
diff --git a/README.md b/README.md
index d8d647a..71be771 100644
--- a/README.md
+++ b/README.md
@@ -1,6 +1,6 @@
# OpenClimb
-This is a FOSS Android app meant to help climbers track their sessions, routes/problems, and overall progress. This app is offline-only and requires no special permissions to run. Its built using Jetpack Compose with Material You support.
+This is a FOSS app meant to help climbers track their sessions, routes/problems, and overall progress. This app is offline-only and requires no special permissions to run. Its built using Jetpack Compose with Material You support on Android and SwiftUI on iOS.
## Download
@@ -11,8 +11,8 @@ You have two options:
## Requirements
-- Android 15+
+- Android 12+ or iOS 17+
## Contribution
-As this is on my private git this will be difficult to do easily. Get in touch and I can figure something out.
\ No newline at end of file
+As this is on my private git this will be difficult to do easily. Get in touch and I can figure something out.
diff --git a/app/.gitignore b/android/app/.gitignore
similarity index 100%
rename from app/.gitignore
rename to android/app/.gitignore
diff --git a/app/build.gradle.kts b/android/app/build.gradle.kts
similarity index 86%
rename from app/build.gradle.kts
rename to android/app/build.gradle.kts
index 1611142..786c604 100644
--- a/app/build.gradle.kts
+++ b/android/app/build.gradle.kts
@@ -26,8 +26,8 @@ android {
release {
isMinifyEnabled = true
proguardFiles(
- getDefaultProguardFile("proguard-android-optimize.txt"),
- "proguard-rules.pro"
+ getDefaultProguardFile("proguard-android-optimize.txt"),
+ "proguard-rules.pro"
)
}
}
@@ -35,60 +35,48 @@ android {
sourceCompatibility = JavaVersion.VERSION_17
targetCompatibility = JavaVersion.VERSION_17
}
-
- java {
- toolchain {
- languageVersion.set(JavaLanguageVersion.of(17))
- }
- }
-
- buildFeatures {
- compose = true
- }
+
+ java { toolchain { languageVersion.set(JavaLanguageVersion.of(17)) } }
+
+ buildFeatures { compose = true }
}
-kotlin {
- compilerOptions {
- jvmTarget.set(JvmTarget.JVM_17)
- }
-}
+kotlin { compilerOptions { jvmTarget.set(JvmTarget.JVM_17) } }
dependencies {
// Core Android libraries
implementation(libs.androidx.core.ktx)
implementation(libs.androidx.lifecycle.runtime.ktx)
implementation(libs.androidx.activity.compose)
-
+
// Compose BOM and UI
implementation(platform(libs.androidx.compose.bom))
implementation(libs.androidx.ui)
implementation(libs.androidx.ui.graphics)
implementation(libs.androidx.ui.tooling.preview)
implementation(libs.androidx.material3)
-
+
// Room Database
implementation(libs.androidx.room.runtime)
implementation(libs.androidx.room.ktx)
ksp(libs.androidx.room.compiler)
-
+
// Navigation
implementation(libs.androidx.navigation.compose)
-
+
// ViewModel
implementation(libs.androidx.lifecycle.viewmodel.compose)
-
+
// Serialization
implementation(libs.kotlinx.serialization.json)
-
+
// Coroutines
implementation(libs.kotlinx.coroutines.android)
-
+
// Image Loading
implementation(libs.coil.compose)
-
-
// Testing
testImplementation(libs.junit)
testImplementation(libs.mockk)
@@ -103,4 +91,5 @@ dependencies {
androidTestImplementation(libs.androidx.ui.test.junit4)
debugImplementation(libs.androidx.ui.tooling)
debugImplementation(libs.androidx.ui.test.manifest)
-}
\ No newline at end of file
+}
+
diff --git a/app/proguard-rules.pro b/android/app/proguard-rules.pro
similarity index 100%
rename from app/proguard-rules.pro
rename to android/app/proguard-rules.pro
diff --git a/app/src/androidTest/java/com/atridad/openclimb/ExampleInstrumentedTest.kt b/android/app/src/androidTest/java/com/atridad/openclimb/ExampleInstrumentedTest.kt
similarity index 100%
rename from app/src/androidTest/java/com/atridad/openclimb/ExampleInstrumentedTest.kt
rename to android/app/src/androidTest/java/com/atridad/openclimb/ExampleInstrumentedTest.kt
diff --git a/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml
similarity index 100%
rename from app/src/main/AndroidManifest.xml
rename to android/app/src/main/AndroidManifest.xml
diff --git a/app/src/main/java/com/atridad/openclimb/MainActivity.kt b/android/app/src/main/java/com/atridad/openclimb/MainActivity.kt
similarity index 100%
rename from app/src/main/java/com/atridad/openclimb/MainActivity.kt
rename to android/app/src/main/java/com/atridad/openclimb/MainActivity.kt
diff --git a/app/src/main/java/com/atridad/openclimb/data/database/Converters.kt b/android/app/src/main/java/com/atridad/openclimb/data/database/Converters.kt
similarity index 100%
rename from app/src/main/java/com/atridad/openclimb/data/database/Converters.kt
rename to android/app/src/main/java/com/atridad/openclimb/data/database/Converters.kt
diff --git a/app/src/main/java/com/atridad/openclimb/data/database/OpenClimbDatabase.kt b/android/app/src/main/java/com/atridad/openclimb/data/database/OpenClimbDatabase.kt
similarity index 100%
rename from app/src/main/java/com/atridad/openclimb/data/database/OpenClimbDatabase.kt
rename to android/app/src/main/java/com/atridad/openclimb/data/database/OpenClimbDatabase.kt
diff --git a/app/src/main/java/com/atridad/openclimb/data/database/dao/AttemptDao.kt b/android/app/src/main/java/com/atridad/openclimb/data/database/dao/AttemptDao.kt
similarity index 100%
rename from app/src/main/java/com/atridad/openclimb/data/database/dao/AttemptDao.kt
rename to android/app/src/main/java/com/atridad/openclimb/data/database/dao/AttemptDao.kt
diff --git a/app/src/main/java/com/atridad/openclimb/data/database/dao/ClimbSessionDao.kt b/android/app/src/main/java/com/atridad/openclimb/data/database/dao/ClimbSessionDao.kt
similarity index 100%
rename from app/src/main/java/com/atridad/openclimb/data/database/dao/ClimbSessionDao.kt
rename to android/app/src/main/java/com/atridad/openclimb/data/database/dao/ClimbSessionDao.kt
diff --git a/app/src/main/java/com/atridad/openclimb/data/database/dao/GymDao.kt b/android/app/src/main/java/com/atridad/openclimb/data/database/dao/GymDao.kt
similarity index 100%
rename from app/src/main/java/com/atridad/openclimb/data/database/dao/GymDao.kt
rename to android/app/src/main/java/com/atridad/openclimb/data/database/dao/GymDao.kt
diff --git a/app/src/main/java/com/atridad/openclimb/data/database/dao/ProblemDao.kt b/android/app/src/main/java/com/atridad/openclimb/data/database/dao/ProblemDao.kt
similarity index 100%
rename from app/src/main/java/com/atridad/openclimb/data/database/dao/ProblemDao.kt
rename to android/app/src/main/java/com/atridad/openclimb/data/database/dao/ProblemDao.kt
diff --git a/app/src/main/java/com/atridad/openclimb/data/model/Attempt.kt b/android/app/src/main/java/com/atridad/openclimb/data/model/Attempt.kt
similarity index 100%
rename from app/src/main/java/com/atridad/openclimb/data/model/Attempt.kt
rename to android/app/src/main/java/com/atridad/openclimb/data/model/Attempt.kt
diff --git a/app/src/main/java/com/atridad/openclimb/data/model/ClimbSession.kt b/android/app/src/main/java/com/atridad/openclimb/data/model/ClimbSession.kt
similarity index 100%
rename from app/src/main/java/com/atridad/openclimb/data/model/ClimbSession.kt
rename to android/app/src/main/java/com/atridad/openclimb/data/model/ClimbSession.kt
diff --git a/app/src/main/java/com/atridad/openclimb/data/model/ClimbType.kt b/android/app/src/main/java/com/atridad/openclimb/data/model/ClimbType.kt
similarity index 100%
rename from app/src/main/java/com/atridad/openclimb/data/model/ClimbType.kt
rename to android/app/src/main/java/com/atridad/openclimb/data/model/ClimbType.kt
diff --git a/app/src/main/java/com/atridad/openclimb/data/model/DifficultySystem.kt b/android/app/src/main/java/com/atridad/openclimb/data/model/DifficultySystem.kt
similarity index 100%
rename from app/src/main/java/com/atridad/openclimb/data/model/DifficultySystem.kt
rename to android/app/src/main/java/com/atridad/openclimb/data/model/DifficultySystem.kt
diff --git a/app/src/main/java/com/atridad/openclimb/data/model/Gym.kt b/android/app/src/main/java/com/atridad/openclimb/data/model/Gym.kt
similarity index 100%
rename from app/src/main/java/com/atridad/openclimb/data/model/Gym.kt
rename to android/app/src/main/java/com/atridad/openclimb/data/model/Gym.kt
diff --git a/app/src/main/java/com/atridad/openclimb/data/model/Problem.kt b/android/app/src/main/java/com/atridad/openclimb/data/model/Problem.kt
similarity index 100%
rename from app/src/main/java/com/atridad/openclimb/data/model/Problem.kt
rename to android/app/src/main/java/com/atridad/openclimb/data/model/Problem.kt
diff --git a/app/src/main/java/com/atridad/openclimb/data/repository/ClimbRepository.kt b/android/app/src/main/java/com/atridad/openclimb/data/repository/ClimbRepository.kt
similarity index 100%
rename from app/src/main/java/com/atridad/openclimb/data/repository/ClimbRepository.kt
rename to android/app/src/main/java/com/atridad/openclimb/data/repository/ClimbRepository.kt
diff --git a/app/src/main/java/com/atridad/openclimb/navigation/BottomNavigationItem.kt b/android/app/src/main/java/com/atridad/openclimb/navigation/BottomNavigationItem.kt
similarity index 100%
rename from app/src/main/java/com/atridad/openclimb/navigation/BottomNavigationItem.kt
rename to android/app/src/main/java/com/atridad/openclimb/navigation/BottomNavigationItem.kt
diff --git a/app/src/main/java/com/atridad/openclimb/navigation/Screen.kt b/android/app/src/main/java/com/atridad/openclimb/navigation/Screen.kt
similarity index 100%
rename from app/src/main/java/com/atridad/openclimb/navigation/Screen.kt
rename to android/app/src/main/java/com/atridad/openclimb/navigation/Screen.kt
diff --git a/app/src/main/java/com/atridad/openclimb/service/SessionTrackingService.kt b/android/app/src/main/java/com/atridad/openclimb/service/SessionTrackingService.kt
similarity index 100%
rename from app/src/main/java/com/atridad/openclimb/service/SessionTrackingService.kt
rename to android/app/src/main/java/com/atridad/openclimb/service/SessionTrackingService.kt
diff --git a/app/src/main/java/com/atridad/openclimb/ui/OpenClimbApp.kt b/android/app/src/main/java/com/atridad/openclimb/ui/OpenClimbApp.kt
similarity index 100%
rename from app/src/main/java/com/atridad/openclimb/ui/OpenClimbApp.kt
rename to android/app/src/main/java/com/atridad/openclimb/ui/OpenClimbApp.kt
diff --git a/app/src/main/java/com/atridad/openclimb/ui/components/ActiveSessionBanner.kt b/android/app/src/main/java/com/atridad/openclimb/ui/components/ActiveSessionBanner.kt
similarity index 100%
rename from app/src/main/java/com/atridad/openclimb/ui/components/ActiveSessionBanner.kt
rename to android/app/src/main/java/com/atridad/openclimb/ui/components/ActiveSessionBanner.kt
diff --git a/app/src/main/java/com/atridad/openclimb/ui/components/FullscreenImageViewer.kt b/android/app/src/main/java/com/atridad/openclimb/ui/components/FullscreenImageViewer.kt
similarity index 100%
rename from app/src/main/java/com/atridad/openclimb/ui/components/FullscreenImageViewer.kt
rename to android/app/src/main/java/com/atridad/openclimb/ui/components/FullscreenImageViewer.kt
diff --git a/app/src/main/java/com/atridad/openclimb/ui/components/ImageDisplay.kt b/android/app/src/main/java/com/atridad/openclimb/ui/components/ImageDisplay.kt
similarity index 100%
rename from app/src/main/java/com/atridad/openclimb/ui/components/ImageDisplay.kt
rename to android/app/src/main/java/com/atridad/openclimb/ui/components/ImageDisplay.kt
diff --git a/app/src/main/java/com/atridad/openclimb/ui/components/ImagePicker.kt b/android/app/src/main/java/com/atridad/openclimb/ui/components/ImagePicker.kt
similarity index 100%
rename from app/src/main/java/com/atridad/openclimb/ui/components/ImagePicker.kt
rename to android/app/src/main/java/com/atridad/openclimb/ui/components/ImagePicker.kt
diff --git a/app/src/main/java/com/atridad/openclimb/ui/components/LineChart.kt b/android/app/src/main/java/com/atridad/openclimb/ui/components/LineChart.kt
similarity index 100%
rename from app/src/main/java/com/atridad/openclimb/ui/components/LineChart.kt
rename to android/app/src/main/java/com/atridad/openclimb/ui/components/LineChart.kt
diff --git a/app/src/main/java/com/atridad/openclimb/ui/components/NotificationPermissionDialog.kt b/android/app/src/main/java/com/atridad/openclimb/ui/components/NotificationPermissionDialog.kt
similarity index 100%
rename from app/src/main/java/com/atridad/openclimb/ui/components/NotificationPermissionDialog.kt
rename to android/app/src/main/java/com/atridad/openclimb/ui/components/NotificationPermissionDialog.kt
diff --git a/app/src/main/java/com/atridad/openclimb/ui/screens/AddEditScreens.kt b/android/app/src/main/java/com/atridad/openclimb/ui/screens/AddEditScreens.kt
similarity index 100%
rename from app/src/main/java/com/atridad/openclimb/ui/screens/AddEditScreens.kt
rename to android/app/src/main/java/com/atridad/openclimb/ui/screens/AddEditScreens.kt
diff --git a/app/src/main/java/com/atridad/openclimb/ui/screens/AnalyticsScreen.kt b/android/app/src/main/java/com/atridad/openclimb/ui/screens/AnalyticsScreen.kt
similarity index 100%
rename from app/src/main/java/com/atridad/openclimb/ui/screens/AnalyticsScreen.kt
rename to android/app/src/main/java/com/atridad/openclimb/ui/screens/AnalyticsScreen.kt
diff --git a/app/src/main/java/com/atridad/openclimb/ui/screens/DetailScreens.kt b/android/app/src/main/java/com/atridad/openclimb/ui/screens/DetailScreens.kt
similarity index 100%
rename from app/src/main/java/com/atridad/openclimb/ui/screens/DetailScreens.kt
rename to android/app/src/main/java/com/atridad/openclimb/ui/screens/DetailScreens.kt
diff --git a/app/src/main/java/com/atridad/openclimb/ui/screens/GymsScreen.kt b/android/app/src/main/java/com/atridad/openclimb/ui/screens/GymsScreen.kt
similarity index 100%
rename from app/src/main/java/com/atridad/openclimb/ui/screens/GymsScreen.kt
rename to android/app/src/main/java/com/atridad/openclimb/ui/screens/GymsScreen.kt
diff --git a/app/src/main/java/com/atridad/openclimb/ui/screens/ProblemsScreen.kt b/android/app/src/main/java/com/atridad/openclimb/ui/screens/ProblemsScreen.kt
similarity index 100%
rename from app/src/main/java/com/atridad/openclimb/ui/screens/ProblemsScreen.kt
rename to android/app/src/main/java/com/atridad/openclimb/ui/screens/ProblemsScreen.kt
diff --git a/app/src/main/java/com/atridad/openclimb/ui/screens/SessionsScreen.kt b/android/app/src/main/java/com/atridad/openclimb/ui/screens/SessionsScreen.kt
similarity index 100%
rename from app/src/main/java/com/atridad/openclimb/ui/screens/SessionsScreen.kt
rename to android/app/src/main/java/com/atridad/openclimb/ui/screens/SessionsScreen.kt
diff --git a/app/src/main/java/com/atridad/openclimb/ui/screens/SettingsScreen.kt b/android/app/src/main/java/com/atridad/openclimb/ui/screens/SettingsScreen.kt
similarity index 100%
rename from app/src/main/java/com/atridad/openclimb/ui/screens/SettingsScreen.kt
rename to android/app/src/main/java/com/atridad/openclimb/ui/screens/SettingsScreen.kt
diff --git a/app/src/main/java/com/atridad/openclimb/ui/theme/Color.kt b/android/app/src/main/java/com/atridad/openclimb/ui/theme/Color.kt
similarity index 100%
rename from app/src/main/java/com/atridad/openclimb/ui/theme/Color.kt
rename to android/app/src/main/java/com/atridad/openclimb/ui/theme/Color.kt
diff --git a/app/src/main/java/com/atridad/openclimb/ui/theme/CustomIcons.kt b/android/app/src/main/java/com/atridad/openclimb/ui/theme/CustomIcons.kt
similarity index 100%
rename from app/src/main/java/com/atridad/openclimb/ui/theme/CustomIcons.kt
rename to android/app/src/main/java/com/atridad/openclimb/ui/theme/CustomIcons.kt
diff --git a/app/src/main/java/com/atridad/openclimb/ui/theme/Theme.kt b/android/app/src/main/java/com/atridad/openclimb/ui/theme/Theme.kt
similarity index 100%
rename from app/src/main/java/com/atridad/openclimb/ui/theme/Theme.kt
rename to android/app/src/main/java/com/atridad/openclimb/ui/theme/Theme.kt
diff --git a/app/src/main/java/com/atridad/openclimb/ui/theme/Type.kt b/android/app/src/main/java/com/atridad/openclimb/ui/theme/Type.kt
similarity index 100%
rename from app/src/main/java/com/atridad/openclimb/ui/theme/Type.kt
rename to android/app/src/main/java/com/atridad/openclimb/ui/theme/Type.kt
diff --git a/app/src/main/java/com/atridad/openclimb/ui/viewmodel/ClimbViewModel.kt b/android/app/src/main/java/com/atridad/openclimb/ui/viewmodel/ClimbViewModel.kt
similarity index 100%
rename from app/src/main/java/com/atridad/openclimb/ui/viewmodel/ClimbViewModel.kt
rename to android/app/src/main/java/com/atridad/openclimb/ui/viewmodel/ClimbViewModel.kt
diff --git a/app/src/main/java/com/atridad/openclimb/ui/viewmodel/ClimbViewModelFactory.kt b/android/app/src/main/java/com/atridad/openclimb/ui/viewmodel/ClimbViewModelFactory.kt
similarity index 100%
rename from app/src/main/java/com/atridad/openclimb/ui/viewmodel/ClimbViewModelFactory.kt
rename to android/app/src/main/java/com/atridad/openclimb/ui/viewmodel/ClimbViewModelFactory.kt
diff --git a/app/src/main/java/com/atridad/openclimb/utils/ImageUtils.kt b/android/app/src/main/java/com/atridad/openclimb/utils/ImageUtils.kt
similarity index 100%
rename from app/src/main/java/com/atridad/openclimb/utils/ImageUtils.kt
rename to android/app/src/main/java/com/atridad/openclimb/utils/ImageUtils.kt
diff --git a/app/src/main/java/com/atridad/openclimb/utils/NotificationPermissionUtils.kt b/android/app/src/main/java/com/atridad/openclimb/utils/NotificationPermissionUtils.kt
similarity index 100%
rename from app/src/main/java/com/atridad/openclimb/utils/NotificationPermissionUtils.kt
rename to android/app/src/main/java/com/atridad/openclimb/utils/NotificationPermissionUtils.kt
diff --git a/app/src/main/java/com/atridad/openclimb/utils/SessionShareUtils.kt b/android/app/src/main/java/com/atridad/openclimb/utils/SessionShareUtils.kt
similarity index 100%
rename from app/src/main/java/com/atridad/openclimb/utils/SessionShareUtils.kt
rename to android/app/src/main/java/com/atridad/openclimb/utils/SessionShareUtils.kt
diff --git a/app/src/main/java/com/atridad/openclimb/utils/ShortcutManager.kt b/android/app/src/main/java/com/atridad/openclimb/utils/ShortcutManager.kt
similarity index 100%
rename from app/src/main/java/com/atridad/openclimb/utils/ShortcutManager.kt
rename to android/app/src/main/java/com/atridad/openclimb/utils/ShortcutManager.kt
diff --git a/app/src/main/java/com/atridad/openclimb/utils/ZipExportImportUtils.kt b/android/app/src/main/java/com/atridad/openclimb/utils/ZipExportImportUtils.kt
similarity index 100%
rename from app/src/main/java/com/atridad/openclimb/utils/ZipExportImportUtils.kt
rename to android/app/src/main/java/com/atridad/openclimb/utils/ZipExportImportUtils.kt
diff --git a/app/src/main/java/com/atridad/openclimb/widget/ClimbStatsWidgetProvider.kt b/android/app/src/main/java/com/atridad/openclimb/widget/ClimbStatsWidgetProvider.kt
similarity index 100%
rename from app/src/main/java/com/atridad/openclimb/widget/ClimbStatsWidgetProvider.kt
rename to android/app/src/main/java/com/atridad/openclimb/widget/ClimbStatsWidgetProvider.kt
diff --git a/app/src/main/res/drawable-night/ic_play_arrow_24.xml b/android/app/src/main/res/drawable-night/ic_play_arrow_24.xml
similarity index 100%
rename from app/src/main/res/drawable-night/ic_play_arrow_24.xml
rename to android/app/src/main/res/drawable-night/ic_play_arrow_24.xml
diff --git a/app/src/main/res/drawable-night/ic_stop_24.xml b/android/app/src/main/res/drawable-night/ic_stop_24.xml
similarity index 100%
rename from app/src/main/res/drawable-night/ic_stop_24.xml
rename to android/app/src/main/res/drawable-night/ic_stop_24.xml
diff --git a/app/src/main/res/drawable/ic_launcher_background.xml b/android/app/src/main/res/drawable/ic_launcher_background.xml
similarity index 100%
rename from app/src/main/res/drawable/ic_launcher_background.xml
rename to android/app/src/main/res/drawable/ic_launcher_background.xml
diff --git a/app/src/main/res/drawable/ic_launcher_foreground.xml b/android/app/src/main/res/drawable/ic_launcher_foreground.xml
similarity index 100%
rename from app/src/main/res/drawable/ic_launcher_foreground.xml
rename to android/app/src/main/res/drawable/ic_launcher_foreground.xml
diff --git a/app/src/main/res/drawable/ic_mountains.xml b/android/app/src/main/res/drawable/ic_mountains.xml
similarity index 100%
rename from app/src/main/res/drawable/ic_mountains.xml
rename to android/app/src/main/res/drawable/ic_mountains.xml
diff --git a/app/src/main/res/drawable/ic_play_arrow_24.xml b/android/app/src/main/res/drawable/ic_play_arrow_24.xml
similarity index 100%
rename from app/src/main/res/drawable/ic_play_arrow_24.xml
rename to android/app/src/main/res/drawable/ic_play_arrow_24.xml
diff --git a/app/src/main/res/drawable/ic_stop_24.xml b/android/app/src/main/res/drawable/ic_stop_24.xml
similarity index 100%
rename from app/src/main/res/drawable/ic_stop_24.xml
rename to android/app/src/main/res/drawable/ic_stop_24.xml
diff --git a/app/src/main/res/drawable/widget_background.xml b/android/app/src/main/res/drawable/widget_background.xml
similarity index 100%
rename from app/src/main/res/drawable/widget_background.xml
rename to android/app/src/main/res/drawable/widget_background.xml
diff --git a/app/src/main/res/drawable/widget_stat_card_background.xml b/android/app/src/main/res/drawable/widget_stat_card_background.xml
similarity index 100%
rename from app/src/main/res/drawable/widget_stat_card_background.xml
rename to android/app/src/main/res/drawable/widget_stat_card_background.xml
diff --git a/app/src/main/res/drawable/widget_status_background.xml b/android/app/src/main/res/drawable/widget_status_background.xml
similarity index 100%
rename from app/src/main/res/drawable/widget_status_background.xml
rename to android/app/src/main/res/drawable/widget_status_background.xml
diff --git a/app/src/main/res/layout/widget_climb_stats.xml b/android/app/src/main/res/layout/widget_climb_stats.xml
similarity index 100%
rename from app/src/main/res/layout/widget_climb_stats.xml
rename to android/app/src/main/res/layout/widget_climb_stats.xml
diff --git a/app/src/main/res/mipmap-anydpi/ic_launcher.xml b/android/app/src/main/res/mipmap-anydpi/ic_launcher.xml
similarity index 100%
rename from app/src/main/res/mipmap-anydpi/ic_launcher.xml
rename to android/app/src/main/res/mipmap-anydpi/ic_launcher.xml
diff --git a/app/src/main/res/mipmap-anydpi/ic_launcher_round.xml b/android/app/src/main/res/mipmap-anydpi/ic_launcher_round.xml
similarity index 100%
rename from app/src/main/res/mipmap-anydpi/ic_launcher_round.xml
rename to android/app/src/main/res/mipmap-anydpi/ic_launcher_round.xml
diff --git a/app/src/main/res/mipmap-hdpi/ic_launcher.webp b/android/app/src/main/res/mipmap-hdpi/ic_launcher.webp
similarity index 100%
rename from app/src/main/res/mipmap-hdpi/ic_launcher.webp
rename to android/app/src/main/res/mipmap-hdpi/ic_launcher.webp
diff --git a/app/src/main/res/mipmap-hdpi/ic_launcher_round.webp b/android/app/src/main/res/mipmap-hdpi/ic_launcher_round.webp
similarity index 100%
rename from app/src/main/res/mipmap-hdpi/ic_launcher_round.webp
rename to android/app/src/main/res/mipmap-hdpi/ic_launcher_round.webp
diff --git a/app/src/main/res/mipmap-mdpi/ic_launcher.webp b/android/app/src/main/res/mipmap-mdpi/ic_launcher.webp
similarity index 100%
rename from app/src/main/res/mipmap-mdpi/ic_launcher.webp
rename to android/app/src/main/res/mipmap-mdpi/ic_launcher.webp
diff --git a/app/src/main/res/mipmap-mdpi/ic_launcher_round.webp b/android/app/src/main/res/mipmap-mdpi/ic_launcher_round.webp
similarity index 100%
rename from app/src/main/res/mipmap-mdpi/ic_launcher_round.webp
rename to android/app/src/main/res/mipmap-mdpi/ic_launcher_round.webp
diff --git a/app/src/main/res/mipmap-xhdpi/ic_launcher.webp b/android/app/src/main/res/mipmap-xhdpi/ic_launcher.webp
similarity index 100%
rename from app/src/main/res/mipmap-xhdpi/ic_launcher.webp
rename to android/app/src/main/res/mipmap-xhdpi/ic_launcher.webp
diff --git a/app/src/main/res/mipmap-xhdpi/ic_launcher_round.webp b/android/app/src/main/res/mipmap-xhdpi/ic_launcher_round.webp
similarity index 100%
rename from app/src/main/res/mipmap-xhdpi/ic_launcher_round.webp
rename to android/app/src/main/res/mipmap-xhdpi/ic_launcher_round.webp
diff --git a/app/src/main/res/mipmap-xxhdpi/ic_launcher.webp b/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.webp
similarity index 100%
rename from app/src/main/res/mipmap-xxhdpi/ic_launcher.webp
rename to android/app/src/main/res/mipmap-xxhdpi/ic_launcher.webp
diff --git a/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp b/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp
similarity index 100%
rename from app/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp
rename to android/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp
diff --git a/app/src/main/res/mipmap-xxxhdpi/ic_launcher.webp b/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.webp
similarity index 100%
rename from app/src/main/res/mipmap-xxxhdpi/ic_launcher.webp
rename to android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.webp
diff --git a/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp b/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp
similarity index 100%
rename from app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp
rename to android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp
diff --git a/app/src/main/res/values-night/colors.xml b/android/app/src/main/res/values-night/colors.xml
similarity index 100%
rename from app/src/main/res/values-night/colors.xml
rename to android/app/src/main/res/values-night/colors.xml
diff --git a/app/src/main/res/values/colors.xml b/android/app/src/main/res/values/colors.xml
similarity index 100%
rename from app/src/main/res/values/colors.xml
rename to android/app/src/main/res/values/colors.xml
diff --git a/app/src/main/res/values/strings.xml b/android/app/src/main/res/values/strings.xml
similarity index 100%
rename from app/src/main/res/values/strings.xml
rename to android/app/src/main/res/values/strings.xml
diff --git a/app/src/main/res/values/themes.xml b/android/app/src/main/res/values/themes.xml
similarity index 100%
rename from app/src/main/res/values/themes.xml
rename to android/app/src/main/res/values/themes.xml
diff --git a/app/src/main/res/xml/backup_rules.xml b/android/app/src/main/res/xml/backup_rules.xml
similarity index 100%
rename from app/src/main/res/xml/backup_rules.xml
rename to android/app/src/main/res/xml/backup_rules.xml
diff --git a/app/src/main/res/xml/data_extraction_rules.xml b/android/app/src/main/res/xml/data_extraction_rules.xml
similarity index 100%
rename from app/src/main/res/xml/data_extraction_rules.xml
rename to android/app/src/main/res/xml/data_extraction_rules.xml
diff --git a/app/src/main/res/xml/file_provider_paths.xml b/android/app/src/main/res/xml/file_provider_paths.xml
similarity index 100%
rename from app/src/main/res/xml/file_provider_paths.xml
rename to android/app/src/main/res/xml/file_provider_paths.xml
diff --git a/app/src/main/res/xml/widget_climb_stats_info.xml b/android/app/src/main/res/xml/widget_climb_stats_info.xml
similarity index 100%
rename from app/src/main/res/xml/widget_climb_stats_info.xml
rename to android/app/src/main/res/xml/widget_climb_stats_info.xml
diff --git a/app/src/test/java/com/atridad/openclimb/ExampleUnitTest.kt b/android/app/src/test/java/com/atridad/openclimb/ExampleUnitTest.kt
similarity index 100%
rename from app/src/test/java/com/atridad/openclimb/ExampleUnitTest.kt
rename to android/app/src/test/java/com/atridad/openclimb/ExampleUnitTest.kt
diff --git a/build.gradle.kts b/android/build.gradle.kts
similarity index 100%
rename from build.gradle.kts
rename to android/build.gradle.kts
diff --git a/gradle.properties b/android/gradle.properties
similarity index 100%
rename from gradle.properties
rename to android/gradle.properties
diff --git a/gradle/libs.versions.toml b/android/gradle/libs.versions.toml
similarity index 100%
rename from gradle/libs.versions.toml
rename to android/gradle/libs.versions.toml
diff --git a/gradle/wrapper/gradle-wrapper.jar b/android/gradle/wrapper/gradle-wrapper.jar
similarity index 100%
rename from gradle/wrapper/gradle-wrapper.jar
rename to android/gradle/wrapper/gradle-wrapper.jar
diff --git a/gradle/wrapper/gradle-wrapper.properties b/android/gradle/wrapper/gradle-wrapper.properties
similarity index 100%
rename from gradle/wrapper/gradle-wrapper.properties
rename to android/gradle/wrapper/gradle-wrapper.properties
diff --git a/gradlew b/android/gradlew
similarity index 100%
rename from gradlew
rename to android/gradlew
diff --git a/gradlew.bat b/android/gradlew.bat
similarity index 100%
rename from gradlew.bat
rename to android/gradlew.bat
diff --git a/settings.gradle.kts b/android/settings.gradle.kts
similarity index 100%
rename from settings.gradle.kts
rename to android/settings.gradle.kts
diff --git a/ios/OpenClimb.xcodeproj/project.pbxproj b/ios/OpenClimb.xcodeproj/project.pbxproj
new file mode 100644
index 0000000..bbc788c
--- /dev/null
+++ b/ios/OpenClimb.xcodeproj/project.pbxproj
@@ -0,0 +1,362 @@
+// !$*UTF8*$!
+{
+ archiveVersion = 1;
+ classes = {
+ };
+ objectVersion = 77;
+ objects = {
+
+/* Begin PBXFileReference section */
+ D24C19682E75002A0045894C /* OpenClimb.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = OpenClimb.app; sourceTree = BUILT_PRODUCTS_DIR; };
+/* End PBXFileReference section */
+
+/* Begin PBXFileSystemSynchronizedBuildFileExceptionSet section */
+ D28C3C8B2E75111D00F7AEE9 /* Exceptions for "OpenClimb" folder in "OpenClimb" target */ = {
+ isa = PBXFileSystemSynchronizedBuildFileExceptionSet;
+ membershipExceptions = (
+ Info.plist,
+ );
+ target = D24C19672E75002A0045894C /* OpenClimb */;
+ };
+/* End PBXFileSystemSynchronizedBuildFileExceptionSet section */
+
+/* Begin PBXFileSystemSynchronizedRootGroup section */
+ D24C196A2E75002A0045894C /* OpenClimb */ = {
+ isa = PBXFileSystemSynchronizedRootGroup;
+ exceptions = (
+ D28C3C8B2E75111D00F7AEE9 /* Exceptions for "OpenClimb" folder in "OpenClimb" target */,
+ );
+ path = OpenClimb;
+ sourceTree = "";
+ };
+/* End PBXFileSystemSynchronizedRootGroup section */
+
+/* Begin PBXFrameworksBuildPhase section */
+ D24C19652E75002A0045894C /* Frameworks */ = {
+ isa = PBXFrameworksBuildPhase;
+ buildActionMask = 2147483647;
+ files = (
+ );
+ runOnlyForDeploymentPostprocessing = 0;
+ };
+/* End PBXFrameworksBuildPhase section */
+
+/* Begin PBXGroup section */
+ D24C195F2E75002A0045894C = {
+ isa = PBXGroup;
+ children = (
+ D24C196A2E75002A0045894C /* OpenClimb */,
+ D24C19692E75002A0045894C /* Products */,
+ );
+ sourceTree = "";
+ };
+ D24C19692E75002A0045894C /* Products */ = {
+ isa = PBXGroup;
+ children = (
+ D24C19682E75002A0045894C /* OpenClimb.app */,
+ );
+ name = Products;
+ sourceTree = "";
+ };
+/* End PBXGroup section */
+
+/* Begin PBXNativeTarget section */
+ D24C19672E75002A0045894C /* OpenClimb */ = {
+ isa = PBXNativeTarget;
+ buildConfigurationList = D24C19732E75002A0045894C /* Build configuration list for PBXNativeTarget "OpenClimb" */;
+ buildPhases = (
+ D24C19642E75002A0045894C /* Sources */,
+ D24C19652E75002A0045894C /* Frameworks */,
+ D24C19662E75002A0045894C /* Resources */,
+ );
+ buildRules = (
+ );
+ dependencies = (
+ );
+ fileSystemSynchronizedGroups = (
+ D24C196A2E75002A0045894C /* OpenClimb */,
+ );
+ name = OpenClimb;
+ packageProductDependencies = (
+ );
+ productName = OpenClimb;
+ productReference = D24C19682E75002A0045894C /* OpenClimb.app */;
+ productType = "com.apple.product-type.application";
+ };
+/* End PBXNativeTarget section */
+
+/* Begin PBXProject section */
+ D24C19602E75002A0045894C /* Project object */ = {
+ isa = PBXProject;
+ attributes = {
+ BuildIndependentTargetsInParallel = 1;
+ LastSwiftUpdateCheck = 2600;
+ LastUpgradeCheck = 2600;
+ TargetAttributes = {
+ D24C19672E75002A0045894C = {
+ CreatedOnToolsVersion = 26.0;
+ };
+ };
+ };
+ buildConfigurationList = D24C19632E75002A0045894C /* Build configuration list for PBXProject "OpenClimb" */;
+ developmentRegion = en;
+ hasScannedForEncodings = 0;
+ knownRegions = (
+ en,
+ Base,
+ );
+ mainGroup = D24C195F2E75002A0045894C;
+ minimizedProjectReferenceProxies = 1;
+ preferredProjectObjectVersion = 77;
+ productRefGroup = D24C19692E75002A0045894C /* Products */;
+ projectDirPath = "";
+ projectRoot = "";
+ targets = (
+ D24C19672E75002A0045894C /* OpenClimb */,
+ );
+ };
+/* End PBXProject section */
+
+/* Begin PBXResourcesBuildPhase section */
+ D24C19662E75002A0045894C /* Resources */ = {
+ isa = PBXResourcesBuildPhase;
+ buildActionMask = 2147483647;
+ files = (
+ );
+ runOnlyForDeploymentPostprocessing = 0;
+ };
+/* End PBXResourcesBuildPhase section */
+
+/* Begin PBXSourcesBuildPhase section */
+ D24C19642E75002A0045894C /* Sources */ = {
+ isa = PBXSourcesBuildPhase;
+ buildActionMask = 2147483647;
+ files = (
+ );
+ runOnlyForDeploymentPostprocessing = 0;
+ };
+/* End PBXSourcesBuildPhase section */
+
+/* Begin XCBuildConfiguration section */
+ D24C19712E75002A0045894C /* Debug */ = {
+ isa = XCBuildConfiguration;
+ buildSettings = {
+ ALWAYS_SEARCH_USER_PATHS = NO;
+ ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
+ CLANG_ANALYZER_NONNULL = YES;
+ CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE;
+ CLANG_CXX_LANGUAGE_STANDARD = "gnu++20";
+ CLANG_ENABLE_MODULES = YES;
+ CLANG_ENABLE_OBJC_ARC = YES;
+ CLANG_ENABLE_OBJC_WEAK = YES;
+ CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES;
+ CLANG_WARN_BOOL_CONVERSION = YES;
+ CLANG_WARN_COMMA = YES;
+ CLANG_WARN_CONSTANT_CONVERSION = YES;
+ CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES;
+ CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;
+ CLANG_WARN_DOCUMENTATION_COMMENTS = YES;
+ CLANG_WARN_EMPTY_BODY = YES;
+ CLANG_WARN_ENUM_CONVERSION = YES;
+ CLANG_WARN_INFINITE_RECURSION = YES;
+ CLANG_WARN_INT_CONVERSION = YES;
+ CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;
+ CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES;
+ CLANG_WARN_OBJC_LITERAL_CONVERSION = YES;
+ CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
+ CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES;
+ CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
+ CLANG_WARN_STRICT_PROTOTYPES = YES;
+ CLANG_WARN_SUSPICIOUS_MOVE = YES;
+ CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
+ CLANG_WARN_UNREACHABLE_CODE = YES;
+ CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
+ COPY_PHASE_STRIP = NO;
+ DEBUG_INFORMATION_FORMAT = dwarf;
+ DEVELOPMENT_TEAM = 4BC9Y2LL4B;
+ ENABLE_STRICT_OBJC_MSGSEND = YES;
+ ENABLE_TESTABILITY = YES;
+ ENABLE_USER_SCRIPT_SANDBOXING = YES;
+ GCC_C_LANGUAGE_STANDARD = gnu17;
+ GCC_DYNAMIC_NO_PIC = NO;
+ GCC_NO_COMMON_BLOCKS = YES;
+ GCC_OPTIMIZATION_LEVEL = 0;
+ GCC_PREPROCESSOR_DEFINITIONS = (
+ "DEBUG=1",
+ "$(inherited)",
+ );
+ GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
+ GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR;
+ GCC_WARN_UNDECLARED_SELECTOR = YES;
+ GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
+ GCC_WARN_UNUSED_FUNCTION = YES;
+ GCC_WARN_UNUSED_VARIABLE = YES;
+ IPHONEOS_DEPLOYMENT_TARGET = 26.0;
+ LOCALIZATION_PREFERS_STRING_CATALOGS = YES;
+ MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE;
+ MTL_FAST_MATH = YES;
+ ONLY_ACTIVE_ARCH = YES;
+ SDKROOT = iphoneos;
+ SWIFT_ACTIVE_COMPILATION_CONDITIONS = "DEBUG $(inherited)";
+ SWIFT_OPTIMIZATION_LEVEL = "-Onone";
+ };
+ name = Debug;
+ };
+ D24C19722E75002A0045894C /* Release */ = {
+ isa = XCBuildConfiguration;
+ buildSettings = {
+ ALWAYS_SEARCH_USER_PATHS = NO;
+ ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
+ CLANG_ANALYZER_NONNULL = YES;
+ CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE;
+ CLANG_CXX_LANGUAGE_STANDARD = "gnu++20";
+ CLANG_ENABLE_MODULES = YES;
+ CLANG_ENABLE_OBJC_ARC = YES;
+ CLANG_ENABLE_OBJC_WEAK = YES;
+ CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES;
+ CLANG_WARN_BOOL_CONVERSION = YES;
+ CLANG_WARN_COMMA = YES;
+ CLANG_WARN_CONSTANT_CONVERSION = YES;
+ CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES;
+ CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;
+ CLANG_WARN_DOCUMENTATION_COMMENTS = YES;
+ CLANG_WARN_EMPTY_BODY = YES;
+ CLANG_WARN_ENUM_CONVERSION = YES;
+ CLANG_WARN_INFINITE_RECURSION = YES;
+ CLANG_WARN_INT_CONVERSION = YES;
+ CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;
+ CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES;
+ CLANG_WARN_OBJC_LITERAL_CONVERSION = YES;
+ CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
+ CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES;
+ CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
+ CLANG_WARN_STRICT_PROTOTYPES = YES;
+ CLANG_WARN_SUSPICIOUS_MOVE = YES;
+ CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
+ CLANG_WARN_UNREACHABLE_CODE = YES;
+ CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
+ COPY_PHASE_STRIP = NO;
+ DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
+ DEVELOPMENT_TEAM = 4BC9Y2LL4B;
+ ENABLE_NS_ASSERTIONS = NO;
+ ENABLE_STRICT_OBJC_MSGSEND = YES;
+ ENABLE_USER_SCRIPT_SANDBOXING = YES;
+ GCC_C_LANGUAGE_STANDARD = gnu17;
+ GCC_NO_COMMON_BLOCKS = YES;
+ GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
+ GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR;
+ GCC_WARN_UNDECLARED_SELECTOR = YES;
+ GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
+ GCC_WARN_UNUSED_FUNCTION = YES;
+ GCC_WARN_UNUSED_VARIABLE = YES;
+ IPHONEOS_DEPLOYMENT_TARGET = 26.0;
+ LOCALIZATION_PREFERS_STRING_CATALOGS = YES;
+ MTL_ENABLE_DEBUG_INFO = NO;
+ MTL_FAST_MATH = YES;
+ SDKROOT = iphoneos;
+ SWIFT_COMPILATION_MODE = wholemodule;
+ VALIDATE_PRODUCT = YES;
+ };
+ name = Release;
+ };
+ D24C19742E75002A0045894C /* Debug */ = {
+ isa = XCBuildConfiguration;
+ buildSettings = {
+ ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
+ ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
+ CODE_SIGN_STYLE = Automatic;
+ CURRENT_PROJECT_VERSION = 1;
+ DEVELOPMENT_TEAM = 4BC9Y2LL4B;
+ ENABLE_PREVIEWS = YES;
+ GENERATE_INFOPLIST_FILE = YES;
+ INFOPLIST_FILE = OpenClimb/Info.plist;
+ INFOPLIST_KEY_CFBundleDisplayName = OpenClimb;
+ INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.sports";
+ INFOPLIST_KEY_LSSupportsOpeningDocumentsInPlace = YES;
+ INFOPLIST_KEY_NSCameraUsageDescription = "OpenClimb needs camera access to take photos of climbing problems.";
+ INFOPLIST_KEY_NSPhotoLibraryUsageDescription = "OpenClimb needs access to your photo library to save and display climbing problem images.";
+ INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES;
+ INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES;
+ INFOPLIST_KEY_UILaunchScreen_Generation = YES;
+ INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
+ INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
+ IPHONEOS_DEPLOYMENT_TARGET = 17.6;
+ LD_RUNPATH_SEARCH_PATHS = (
+ "$(inherited)",
+ "@executable_path/Frameworks",
+ );
+ MARKETING_VERSION = 1.5.0;
+ PRODUCT_BUNDLE_IDENTIFIER = com.atridad.OpenClimb;
+ PRODUCT_NAME = "$(TARGET_NAME)";
+ STRING_CATALOG_GENERATE_SYMBOLS = YES;
+ SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor;
+ SWIFT_EMIT_LOC_STRINGS = YES;
+ SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES;
+ SWIFT_VERSION = 5.0;
+ TARGETED_DEVICE_FAMILY = "1,2";
+ };
+ name = Debug;
+ };
+ D24C19752E75002A0045894C /* Release */ = {
+ isa = XCBuildConfiguration;
+ buildSettings = {
+ ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
+ ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
+ CODE_SIGN_STYLE = Automatic;
+ CURRENT_PROJECT_VERSION = 1;
+ DEVELOPMENT_TEAM = 4BC9Y2LL4B;
+ ENABLE_PREVIEWS = YES;
+ GENERATE_INFOPLIST_FILE = YES;
+ INFOPLIST_FILE = OpenClimb/Info.plist;
+ INFOPLIST_KEY_CFBundleDisplayName = OpenClimb;
+ INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.sports";
+ INFOPLIST_KEY_LSSupportsOpeningDocumentsInPlace = YES;
+ INFOPLIST_KEY_NSCameraUsageDescription = "OpenClimb needs camera access to take photos of climbing problems.";
+ INFOPLIST_KEY_NSPhotoLibraryUsageDescription = "OpenClimb needs access to your photo library to save and display climbing problem images.";
+ INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES;
+ INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES;
+ INFOPLIST_KEY_UILaunchScreen_Generation = YES;
+ INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
+ INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
+ IPHONEOS_DEPLOYMENT_TARGET = 17.6;
+ LD_RUNPATH_SEARCH_PATHS = (
+ "$(inherited)",
+ "@executable_path/Frameworks",
+ );
+ MARKETING_VERSION = 1.5.0;
+ PRODUCT_BUNDLE_IDENTIFIER = com.atridad.OpenClimb;
+ PRODUCT_NAME = "$(TARGET_NAME)";
+ STRING_CATALOG_GENERATE_SYMBOLS = YES;
+ SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor;
+ SWIFT_EMIT_LOC_STRINGS = YES;
+ SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES;
+ SWIFT_VERSION = 5.0;
+ TARGETED_DEVICE_FAMILY = "1,2";
+ };
+ name = Release;
+ };
+/* End XCBuildConfiguration section */
+
+/* Begin XCConfigurationList section */
+ D24C19632E75002A0045894C /* Build configuration list for PBXProject "OpenClimb" */ = {
+ isa = XCConfigurationList;
+ buildConfigurations = (
+ D24C19712E75002A0045894C /* Debug */,
+ D24C19722E75002A0045894C /* Release */,
+ );
+ defaultConfigurationIsVisible = 0;
+ defaultConfigurationName = Release;
+ };
+ D24C19732E75002A0045894C /* Build configuration list for PBXNativeTarget "OpenClimb" */ = {
+ isa = XCConfigurationList;
+ buildConfigurations = (
+ D24C19742E75002A0045894C /* Debug */,
+ D24C19752E75002A0045894C /* Release */,
+ );
+ defaultConfigurationIsVisible = 0;
+ defaultConfigurationName = Release;
+ };
+/* End XCConfigurationList section */
+ };
+ rootObject = D24C19602E75002A0045894C /* Project object */;
+}
diff --git a/ios/OpenClimb.xcodeproj/project.xcworkspace/contents.xcworkspacedata b/ios/OpenClimb.xcodeproj/project.xcworkspace/contents.xcworkspacedata
new file mode 100644
index 0000000..919434a
--- /dev/null
+++ b/ios/OpenClimb.xcodeproj/project.xcworkspace/contents.xcworkspacedata
@@ -0,0 +1,7 @@
+
+
+
+
+
diff --git a/ios/OpenClimb.xcodeproj/project.xcworkspace/xcuserdata/atridad.xcuserdatad/IDEFindNavigatorScopes.plist b/ios/OpenClimb.xcodeproj/project.xcworkspace/xcuserdata/atridad.xcuserdatad/IDEFindNavigatorScopes.plist
new file mode 100644
index 0000000..5dd5da8
--- /dev/null
+++ b/ios/OpenClimb.xcodeproj/project.xcworkspace/xcuserdata/atridad.xcuserdatad/IDEFindNavigatorScopes.plist
@@ -0,0 +1,5 @@
+
+
+
+
+
diff --git a/ios/OpenClimb.xcodeproj/project.xcworkspace/xcuserdata/atridad.xcuserdatad/UserInterfaceState.xcuserstate b/ios/OpenClimb.xcodeproj/project.xcworkspace/xcuserdata/atridad.xcuserdatad/UserInterfaceState.xcuserstate
new file mode 100644
index 0000000..9e7c81b
Binary files /dev/null and b/ios/OpenClimb.xcodeproj/project.xcworkspace/xcuserdata/atridad.xcuserdatad/UserInterfaceState.xcuserstate differ
diff --git a/ios/OpenClimb.xcodeproj/xcuserdata/atridad.xcuserdatad/xcschemes/xcschememanagement.plist b/ios/OpenClimb.xcodeproj/xcuserdata/atridad.xcuserdatad/xcschemes/xcschememanagement.plist
new file mode 100644
index 0000000..4327198
--- /dev/null
+++ b/ios/OpenClimb.xcodeproj/xcuserdata/atridad.xcuserdatad/xcschemes/xcschememanagement.plist
@@ -0,0 +1,14 @@
+
+
+
+
+ SchemeUserState
+
+ OpenClimb.xcscheme_^#shared#^_
+
+ orderHint
+ 0
+
+
+
+
diff --git a/ios/OpenClimb/Assets.xcassets/AccentColor.colorset/Contents.json b/ios/OpenClimb/Assets.xcassets/AccentColor.colorset/Contents.json
new file mode 100644
index 0000000..eb87897
--- /dev/null
+++ b/ios/OpenClimb/Assets.xcassets/AccentColor.colorset/Contents.json
@@ -0,0 +1,11 @@
+{
+ "colors" : [
+ {
+ "idiom" : "universal"
+ }
+ ],
+ "info" : {
+ "author" : "xcode",
+ "version" : 1
+ }
+}
diff --git a/ios/OpenClimb/Assets.xcassets/AppIcon.appiconset/Contents.json b/ios/OpenClimb/Assets.xcassets/AppIcon.appiconset/Contents.json
new file mode 100644
index 0000000..2305880
--- /dev/null
+++ b/ios/OpenClimb/Assets.xcassets/AppIcon.appiconset/Contents.json
@@ -0,0 +1,35 @@
+{
+ "images" : [
+ {
+ "idiom" : "universal",
+ "platform" : "ios",
+ "size" : "1024x1024"
+ },
+ {
+ "appearances" : [
+ {
+ "appearance" : "luminosity",
+ "value" : "dark"
+ }
+ ],
+ "idiom" : "universal",
+ "platform" : "ios",
+ "size" : "1024x1024"
+ },
+ {
+ "appearances" : [
+ {
+ "appearance" : "luminosity",
+ "value" : "tinted"
+ }
+ ],
+ "idiom" : "universal",
+ "platform" : "ios",
+ "size" : "1024x1024"
+ }
+ ],
+ "info" : {
+ "author" : "xcode",
+ "version" : 1
+ }
+}
diff --git a/ios/OpenClimb/Assets.xcassets/Contents.json b/ios/OpenClimb/Assets.xcassets/Contents.json
new file mode 100644
index 0000000..73c0059
--- /dev/null
+++ b/ios/OpenClimb/Assets.xcassets/Contents.json
@@ -0,0 +1,6 @@
+{
+ "info" : {
+ "author" : "xcode",
+ "version" : 1
+ }
+}
diff --git a/ios/OpenClimb/ContentView.swift b/ios/OpenClimb/ContentView.swift
new file mode 100644
index 0000000..d9d094e
--- /dev/null
+++ b/ios/OpenClimb/ContentView.swift
@@ -0,0 +1,114 @@
+//
+// ContentView.swift
+// OpenClimb
+//
+// Created by OpenClimb on 2025-01-17.
+//
+
+import SwiftUI
+
+struct ContentView: View {
+ @StateObject private var dataManager = ClimbingDataManager()
+ @State private var selectedTab = 0
+
+ var body: some View {
+ TabView(selection: $selectedTab) {
+ SessionsView()
+ .tabItem {
+ Image(systemName: "play.fill")
+ Text("Sessions")
+ }
+ .tag(0)
+
+ ProblemsView()
+ .tabItem {
+ Image(systemName: "star.fill")
+ Text("Problems")
+ }
+ .tag(1)
+
+ AnalyticsView()
+ .tabItem {
+ Image(systemName: "chart.bar.fill")
+ Text("Analytics")
+ }
+ .tag(2)
+
+ GymsView()
+ .tabItem {
+ Image(systemName: "location.fill")
+ Text("Gyms")
+ }
+ .tag(3)
+
+ SettingsView()
+ .tabItem {
+ Image(systemName: "gear")
+ Text("Settings")
+ }
+ .tag(4)
+ }
+ .environmentObject(dataManager)
+ .overlay(alignment: .top) {
+ if let message = dataManager.successMessage {
+ SuccessMessageView(message: message)
+ .transition(.move(edge: .top).combined(with: .opacity))
+ .animation(.easeInOut, value: dataManager.successMessage)
+ }
+
+ if let error = dataManager.errorMessage {
+ ErrorMessageView(message: error)
+ .transition(.move(edge: .top).combined(with: .opacity))
+ .animation(.easeInOut, value: dataManager.errorMessage)
+ }
+ }
+ }
+}
+
+struct SuccessMessageView: View {
+ let message: String
+
+ var body: some View {
+ HStack {
+ Image(systemName: "checkmark.circle.fill")
+ .foregroundColor(.green)
+ Text(message)
+ .font(.subheadline)
+ .foregroundColor(.primary)
+ }
+ .padding()
+ .background(
+ RoundedRectangle(cornerRadius: 12)
+ .fill(.regularMaterial)
+ .shadow(radius: 4)
+ )
+ .padding(.horizontal)
+ .padding(.top, 8)
+ }
+}
+
+struct ErrorMessageView: View {
+ let message: String
+
+ var body: some View {
+ HStack {
+ Image(systemName: "exclamationmark.triangle.fill")
+ .foregroundColor(.red)
+ Text(message)
+ .font(.subheadline)
+ .foregroundColor(.primary)
+ }
+ .padding()
+ .background(
+ RoundedRectangle(cornerRadius: 12)
+ .fill(.regularMaterial)
+ .shadow(radius: 4)
+ )
+ .padding(.horizontal)
+ .padding(.top, 8)
+ }
+}
+
+#Preview {
+ ContentView()
+}
diff --git a/ios/OpenClimb/Info.plist b/ios/OpenClimb/Info.plist
new file mode 100644
index 0000000..ff579a6
--- /dev/null
+++ b/ios/OpenClimb/Info.plist
@@ -0,0 +1,8 @@
+
+
+
+
+ UIFileSharingEnabled
+
+
+
diff --git a/ios/OpenClimb/Models/DataModels.swift b/ios/OpenClimb/Models/DataModels.swift
new file mode 100644
index 0000000..a2430ca
--- /dev/null
+++ b/ios/OpenClimb/Models/DataModels.swift
@@ -0,0 +1,561 @@
+//
+// DataModels.swift
+// OpenClimb
+//
+// Created by OpenClimb on 2025-01-17.
+//
+
+import Foundation
+import SwiftUI
+
+enum ClimbType: String, CaseIterable, Codable {
+ case rope = "ROPE"
+ case boulder = "BOULDER"
+
+ var displayName: String {
+ switch self {
+ case .rope:
+ return "Rope"
+ case .boulder:
+ return "Bouldering"
+ }
+ }
+}
+
+enum DifficultySystem: String, CaseIterable, Codable {
+ case vScale = "V_SCALE"
+ case font = "FONT"
+ case yds = "YDS"
+ case custom = "CUSTOM"
+
+ var displayName: String {
+ switch self {
+ case .vScale:
+ return "V Scale"
+ case .font:
+ return "Font Scale"
+ case .yds:
+ return "YDS (Yosemite)"
+ case .custom:
+ return "Custom"
+ }
+ }
+
+ var isBoulderingSystem: Bool {
+ switch self {
+ case .vScale, .font:
+ return true
+ case .yds:
+ return false
+ case .custom:
+ return true
+ }
+ }
+
+ var isRopeSystem: Bool {
+ switch self {
+ case .yds:
+ return true
+ case .vScale, .font:
+ return false
+ case .custom:
+ return true
+ }
+ }
+
+ var availableGrades: [String] {
+ switch self {
+ case .vScale:
+ return [
+ "VB", "V0", "V1", "V2", "V3", "V4", "V5", "V6", "V7", "V8", "V9", "V10", "V11",
+ "V12", "V13", "V14", "V15", "V16", "V17",
+ ]
+ case .font:
+ return [
+ "3", "4A", "4B", "4C", "5A", "5B", "5C", "6A", "6A+", "6B", "6B+", "6C", "6C+",
+ "7A", "7A+", "7B", "7B+", "7C", "7C+", "8A", "8A+", "8B", "8B+", "8C", "8C+",
+ ]
+ case .yds:
+ return [
+ "5.0", "5.1", "5.2", "5.3", "5.4", "5.5", "5.6", "5.7", "5.8", "5.9", "5.10a",
+ "5.10b", "5.10c", "5.10d", "5.11a", "5.11b", "5.11c", "5.11d", "5.12a", "5.12b",
+ "5.12c", "5.12d", "5.13a", "5.13b", "5.13c", "5.13d", "5.14a", "5.14b", "5.14c",
+ "5.14d", "5.15a", "5.15b", "5.15c", "5.15d",
+ ]
+ case .custom:
+ return []
+ }
+ }
+
+ static func systemsForClimbType(_ climbType: ClimbType) -> [DifficultySystem] {
+ switch climbType {
+ case .boulder:
+ return allCases.filter { $0.isBoulderingSystem }
+ case .rope:
+ return allCases.filter { $0.isRopeSystem }
+ }
+ }
+}
+
+enum AttemptResult: String, CaseIterable, Codable {
+ case success = "SUCCESS"
+ case fall = "FALL"
+ case noProgress = "NO_PROGRESS"
+ case flash = "FLASH"
+
+ var displayName: String {
+ switch self {
+ case .success:
+ return "Success"
+ case .fall:
+ return "Fall"
+ case .noProgress:
+ return "No Progress"
+ case .flash:
+ return "Flash"
+ }
+ }
+
+ var isSuccessful: Bool {
+ return self == .success || self == .flash
+ }
+}
+
+enum SessionStatus: String, CaseIterable, Codable {
+ case active = "ACTIVE"
+ case completed = "COMPLETED"
+ case paused = "PAUSED"
+
+ var displayName: String {
+ switch self {
+ case .active:
+ return "Active"
+ case .completed:
+ return "Completed"
+ case .paused:
+ return "Paused"
+ }
+ }
+}
+
+struct DifficultyGrade: Codable, Hashable {
+ let system: DifficultySystem
+ let grade: String
+ let numericValue: Int
+
+ init(system: DifficultySystem, grade: String) {
+ self.system = system
+ self.grade = grade
+ self.numericValue = Self.calculateNumericValue(system: system, grade: grade)
+ }
+
+ private static func calculateNumericValue(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
+ }
+ }
+}
+
+struct Gym: Identifiable, Codable, Hashable {
+ let id: UUID
+ let name: String
+ let location: String?
+ let supportedClimbTypes: [ClimbType]
+ let difficultySystems: [DifficultySystem]
+ let customDifficultyGrades: [String]
+ let notes: String?
+ let createdAt: Date
+ let updatedAt: Date
+
+ init(
+ name: String, location: String? = nil, supportedClimbTypes: [ClimbType],
+ difficultySystems: [DifficultySystem], customDifficultyGrades: [String] = [],
+ notes: String? = nil
+ ) {
+ self.id = UUID()
+ self.name = name
+ self.location = location
+ self.supportedClimbTypes = supportedClimbTypes
+ self.difficultySystems = difficultySystems
+ self.customDifficultyGrades = customDifficultyGrades
+ self.notes = notes
+ let now = Date()
+ self.createdAt = now
+ self.updatedAt = now
+ }
+
+ func updated(
+ name: String? = nil, location: String? = nil, supportedClimbTypes: [ClimbType]? = nil,
+ difficultySystems: [DifficultySystem]? = nil, customDifficultyGrades: [String]? = nil,
+ notes: String? = nil
+ ) -> Gym {
+ return Gym(
+ id: self.id,
+ name: name ?? self.name,
+ location: location ?? self.location,
+ supportedClimbTypes: supportedClimbTypes ?? self.supportedClimbTypes,
+ difficultySystems: difficultySystems ?? self.difficultySystems,
+ customDifficultyGrades: customDifficultyGrades ?? self.customDifficultyGrades,
+ notes: notes ?? self.notes,
+ createdAt: self.createdAt,
+ updatedAt: Date()
+ )
+ }
+
+ private init(
+ id: UUID, name: String, location: String?, supportedClimbTypes: [ClimbType],
+ difficultySystems: [DifficultySystem], customDifficultyGrades: [String], notes: String?,
+ createdAt: Date, updatedAt: Date
+ ) {
+ self.id = id
+ self.name = name
+ self.location = location
+ self.supportedClimbTypes = supportedClimbTypes
+ self.difficultySystems = difficultySystems
+ self.customDifficultyGrades = customDifficultyGrades
+ self.notes = notes
+ self.createdAt = createdAt
+ self.updatedAt = updatedAt
+ }
+
+ static func fromImport(
+ id: UUID, name: String, location: String?, supportedClimbTypes: [ClimbType],
+ difficultySystems: [DifficultySystem], customDifficultyGrades: [String], notes: String?,
+ createdAt: Date, updatedAt: Date
+ ) -> Gym {
+ return Gym(
+ id: id,
+ name: name,
+ location: location,
+ supportedClimbTypes: supportedClimbTypes,
+ difficultySystems: difficultySystems,
+ customDifficultyGrades: customDifficultyGrades,
+ notes: notes,
+ createdAt: createdAt,
+ updatedAt: updatedAt
+ )
+ }
+}
+
+struct Problem: Identifiable, Codable, Hashable {
+ let id: UUID
+ let gymId: UUID
+ let name: String?
+ let description: String?
+ let climbType: ClimbType
+ let difficulty: DifficultyGrade
+ let setter: String?
+ let tags: [String]
+ let location: String?
+ let imagePaths: [String]
+ let isActive: Bool
+ let dateSet: Date?
+ let notes: String?
+ let createdAt: Date
+ let updatedAt: Date
+
+ init(
+ gymId: UUID, name: String? = nil, description: String? = nil, climbType: ClimbType,
+ difficulty: DifficultyGrade, setter: String? = nil, tags: [String] = [],
+ location: String? = nil, imagePaths: [String] = [], dateSet: Date? = nil,
+ notes: String? = nil
+ ) {
+ self.id = UUID()
+ self.gymId = gymId
+ self.name = name
+ self.description = description
+ self.climbType = climbType
+ self.difficulty = difficulty
+ self.setter = setter
+ self.tags = tags
+ self.location = location
+ self.imagePaths = imagePaths
+ self.isActive = true
+ self.dateSet = dateSet
+ self.notes = notes
+ let now = Date()
+ self.createdAt = now
+ self.updatedAt = now
+ }
+
+ func updated(
+ name: String? = nil, description: String? = nil, climbType: ClimbType? = nil,
+ difficulty: DifficultyGrade? = nil, setter: String? = nil, tags: [String]? = nil,
+ location: String? = nil, imagePaths: [String]? = nil, isActive: Bool? = nil,
+ dateSet: Date? = nil, notes: String? = nil
+ ) -> Problem {
+ return Problem(
+ id: self.id,
+ gymId: self.gymId,
+ name: name ?? self.name,
+ description: description ?? self.description,
+ climbType: climbType ?? self.climbType,
+ difficulty: difficulty ?? self.difficulty,
+ setter: setter ?? self.setter,
+ tags: tags ?? self.tags,
+ location: location ?? self.location,
+ imagePaths: imagePaths ?? self.imagePaths,
+ isActive: isActive ?? self.isActive,
+ dateSet: dateSet ?? self.dateSet,
+ notes: notes ?? self.notes,
+ createdAt: self.createdAt,
+ updatedAt: Date()
+ )
+ }
+
+ private init(
+ id: UUID, gymId: UUID, name: String?, description: String?, climbType: ClimbType,
+ difficulty: DifficultyGrade, setter: String?, tags: [String], location: String?,
+ imagePaths: [String], isActive: Bool, dateSet: Date?, notes: String?, createdAt: Date,
+ updatedAt: Date
+ ) {
+ self.id = id
+ self.gymId = gymId
+ self.name = name
+ self.description = description
+ self.climbType = climbType
+ self.difficulty = difficulty
+ self.setter = setter
+ self.tags = tags
+ self.location = location
+ self.imagePaths = imagePaths
+ self.isActive = isActive
+ self.dateSet = dateSet
+ self.notes = notes
+ self.createdAt = createdAt
+ self.updatedAt = updatedAt
+ }
+
+ static func fromImport(
+ id: UUID, gymId: UUID, name: String?, description: String?, climbType: ClimbType,
+ difficulty: DifficultyGrade, setter: String?, tags: [String], location: String?,
+ imagePaths: [String], isActive: Bool, dateSet: Date?, notes: String?, createdAt: Date,
+ updatedAt: Date
+ ) -> Problem {
+ return Problem(
+ id: id,
+ gymId: gymId,
+ name: name,
+ description: description,
+ climbType: climbType,
+ difficulty: difficulty,
+ setter: setter,
+ tags: tags,
+ location: location,
+ imagePaths: imagePaths,
+ isActive: isActive,
+ dateSet: dateSet,
+ notes: notes,
+ createdAt: createdAt,
+ updatedAt: updatedAt
+ )
+ }
+}
+
+struct ClimbSession: Identifiable, Codable, Hashable {
+ let id: UUID
+ let gymId: UUID
+ let date: Date
+ let startTime: Date?
+ let endTime: Date?
+ let duration: Int? // Duration in minutes
+ let status: SessionStatus
+ let notes: String?
+ let createdAt: Date
+ let updatedAt: Date
+
+ init(gymId: UUID, notes: String? = nil) {
+ self.id = UUID()
+ self.gymId = gymId
+ let now = Date()
+ self.date = now
+ self.startTime = now
+ self.endTime = nil
+ self.duration = nil
+ self.status = .active
+ self.notes = notes
+ self.createdAt = now
+ self.updatedAt = now
+ }
+
+ func completed() -> ClimbSession {
+ let endTime = Date()
+ let durationMinutes =
+ startTime != nil ? Int(endTime.timeIntervalSince(startTime!) / 60) : nil
+
+ return ClimbSession(
+ id: self.id,
+ gymId: self.gymId,
+ date: self.date,
+ startTime: self.startTime,
+ endTime: endTime,
+ duration: durationMinutes,
+ status: .completed,
+ notes: self.notes,
+ createdAt: self.createdAt,
+ updatedAt: Date()
+ )
+ }
+
+ func updated(notes: String? = nil, status: SessionStatus? = nil) -> ClimbSession {
+ return ClimbSession(
+ id: self.id,
+ gymId: self.gymId,
+ date: self.date,
+ startTime: self.startTime,
+ endTime: self.endTime,
+ duration: self.duration,
+ status: status ?? self.status,
+ notes: notes ?? self.notes,
+ createdAt: self.createdAt,
+ updatedAt: Date()
+ )
+ }
+
+ private init(
+ id: UUID, gymId: UUID, date: Date, startTime: Date?, endTime: Date?, duration: Int?,
+ status: SessionStatus, notes: String?, createdAt: Date, updatedAt: Date
+ ) {
+ self.id = id
+ self.gymId = gymId
+ self.date = date
+ self.startTime = startTime
+ self.endTime = endTime
+ self.duration = duration
+ self.status = status
+ self.notes = notes
+ self.createdAt = createdAt
+ self.updatedAt = updatedAt
+ }
+
+ static func fromImport(
+ id: UUID, gymId: UUID, date: Date, startTime: Date?, endTime: Date?, duration: Int?,
+ status: SessionStatus, notes: String?, createdAt: Date, updatedAt: Date
+ ) -> ClimbSession {
+ return ClimbSession(
+ id: id,
+ gymId: gymId,
+ date: date,
+ startTime: startTime,
+ endTime: endTime,
+ duration: duration,
+ status: status,
+ notes: notes,
+ createdAt: createdAt,
+ updatedAt: updatedAt
+ )
+ }
+}
+
+struct Attempt: Identifiable, Codable, Hashable {
+ let id: UUID
+ let sessionId: UUID
+ let problemId: UUID
+ let result: AttemptResult
+ let highestHold: String?
+ let notes: String?
+ let duration: Int?
+ let restTime: Int?
+ let timestamp: Date
+ let createdAt: Date
+
+ init(
+ sessionId: UUID, problemId: UUID, result: AttemptResult, highestHold: String? = nil,
+ notes: String? = nil, duration: Int? = nil, restTime: Int? = nil, timestamp: Date = Date()
+ ) {
+ self.id = UUID()
+ self.sessionId = sessionId
+ self.problemId = problemId
+ self.result = result
+ self.highestHold = highestHold
+ self.notes = notes
+ self.duration = duration
+ self.restTime = restTime
+ self.timestamp = timestamp
+ self.createdAt = Date()
+ }
+
+ func updated(
+ result: AttemptResult? = nil, highestHold: String? = nil, notes: String? = nil,
+ duration: Int? = nil, restTime: Int? = nil
+ ) -> Attempt {
+ return Attempt(
+ id: self.id,
+ sessionId: self.sessionId,
+ problemId: self.problemId,
+ result: result ?? self.result,
+ highestHold: highestHold ?? self.highestHold,
+ notes: notes ?? self.notes,
+ duration: duration ?? self.duration,
+ restTime: restTime ?? self.restTime,
+ timestamp: self.timestamp,
+ createdAt: self.createdAt
+ )
+ }
+
+ private init(
+ id: UUID, sessionId: UUID, problemId: UUID, result: AttemptResult, highestHold: String?,
+ notes: String?, duration: Int?, restTime: Int?, timestamp: Date, createdAt: Date
+ ) {
+ self.id = id
+ self.sessionId = sessionId
+ self.problemId = problemId
+ self.result = result
+ self.highestHold = highestHold
+ self.notes = notes
+ self.duration = duration
+ self.restTime = restTime
+ self.timestamp = timestamp
+ self.createdAt = createdAt
+ }
+
+ static func fromImport(
+ id: UUID, sessionId: UUID, problemId: UUID, result: AttemptResult, highestHold: String?,
+ notes: String?, duration: Int?, restTime: Int?, timestamp: Date, createdAt: Date
+ ) -> Attempt {
+ return Attempt(
+ id: id,
+ sessionId: sessionId,
+ problemId: problemId,
+ result: result,
+ highestHold: highestHold,
+ notes: notes,
+ duration: duration,
+ restTime: restTime,
+ timestamp: timestamp,
+ createdAt: createdAt
+ )
+ }
+}
+
+extension DifficultyGrade: Comparable {
+ static func < (lhs: DifficultyGrade, rhs: DifficultyGrade) -> Bool {
+ if lhs.system != rhs.system {
+ return false // Can't compare different systems
+ }
+ return lhs.numericValue < rhs.numericValue
+ }
+}
diff --git a/ios/OpenClimb/OpenClimbApp.swift b/ios/OpenClimb/OpenClimbApp.swift
new file mode 100644
index 0000000..1ab2204
--- /dev/null
+++ b/ios/OpenClimb/OpenClimbApp.swift
@@ -0,0 +1,17 @@
+//
+// OpenClimbApp.swift
+// OpenClimb
+//
+// Created by OpenClimb on 2025-01-17.
+//
+
+import SwiftUI
+
+@main
+struct OpenClimbApp: App {
+ var body: some Scene {
+ WindowGroup {
+ ContentView()
+ }
+ }
+}
diff --git a/ios/OpenClimb/Utils/ZipUtils.swift b/ios/OpenClimb/Utils/ZipUtils.swift
new file mode 100644
index 0000000..ecd7314
--- /dev/null
+++ b/ios/OpenClimb/Utils/ZipUtils.swift
@@ -0,0 +1,654 @@
+//
+// ZipUtils.swift
+// OpenClimb
+//
+// Created by OpenClimb on 2025-01-17.
+//
+
+import Compression
+import Foundation
+import zlib
+
+struct ZipUtils {
+
+ private static let DATA_JSON_FILENAME = "data.json"
+ private static let IMAGES_DIR_NAME = "images"
+ private static let METADATA_FILENAME = "metadata.txt"
+
+ static func createExportZip(
+ exportData: ClimbDataExport,
+ referencedImagePaths: Set
+ ) throws -> Data {
+
+ var zipData = Data()
+ var centralDirectory = Data()
+ var fileEntries: [(name: String, data: Data, offset: UInt32)] = []
+ var currentOffset: UInt32 = 0
+
+ let metadata = createMetadata(
+ exportData: exportData, referencedImagePaths: referencedImagePaths)
+ let metadataData = metadata.data(using: .utf8) ?? Data()
+ try addFileToZip(
+ filename: METADATA_FILENAME,
+ fileData: metadataData,
+ zipData: &zipData,
+ fileEntries: &fileEntries,
+ currentOffset: ¤tOffset
+ )
+
+ let encoder = JSONEncoder()
+ encoder.outputFormatting = .prettyPrinted
+ encoder.dateEncodingStrategy = .custom { date, encoder in
+ let formatter = DateFormatter()
+ formatter.dateFormat = "yyyy-MM-dd'T'HH:mm:ss.SSSSSS"
+ var container = encoder.singleValueContainer()
+ try container.encode(formatter.string(from: date))
+ }
+ let jsonData = try encoder.encode(exportData)
+ try addFileToZip(
+ filename: DATA_JSON_FILENAME,
+ fileData: jsonData,
+ zipData: &zipData,
+ fileEntries: &fileEntries,
+ currentOffset: ¤tOffset
+ )
+
+ print("Processing \(referencedImagePaths.count) referenced image paths")
+ var successfulImages = 0
+
+ for imagePath in referencedImagePaths {
+ print("Processing image path: \(imagePath)")
+ let imageURL = URL(fileURLWithPath: imagePath)
+ let imageName = imageURL.lastPathComponent
+ print("Image name: \(imageName)")
+
+ if FileManager.default.fileExists(atPath: imagePath) {
+ print("Image file exists at: \(imagePath)")
+ do {
+ let imageData = try Data(contentsOf: imageURL)
+ print("Image data size: \(imageData.count) bytes")
+ if imageData.count > 0 {
+ let imageEntryName = "\(IMAGES_DIR_NAME)/\(imageName)"
+ try addFileToZip(
+ filename: imageEntryName,
+ fileData: imageData,
+ zipData: &zipData,
+ fileEntries: &fileEntries,
+ currentOffset: ¤tOffset
+ )
+ successfulImages += 1
+ print("Successfully added image to ZIP: \(imageEntryName)")
+ } else {
+ print("Image data is empty for: \(imagePath)")
+ }
+ } catch {
+ print("Failed to read image data for \(imagePath): \(error)")
+ }
+ } else {
+ print("Image file does not exist at: \(imagePath)")
+ }
+ }
+
+ print("Export completed: \(successfulImages)/\(referencedImagePaths.count) images included")
+
+ for entry in fileEntries {
+ let centralDirEntry = createCentralDirectoryEntry(
+ filename: entry.name,
+ fileData: entry.data,
+ localHeaderOffset: entry.offset
+ )
+ centralDirectory.append(centralDirEntry)
+ }
+
+ let centralDirOffset = UInt32(zipData.count)
+ zipData.append(centralDirectory)
+
+ let endOfCentralDir = createEndOfCentralDirectory(
+ numEntries: UInt16(fileEntries.count),
+ centralDirSize: UInt32(centralDirectory.count),
+ centralDirOffset: centralDirOffset
+ )
+ zipData.append(endOfCentralDir)
+
+ return zipData
+ }
+
+ static func extractImportZip(data: Data) throws -> ImportResult {
+ print("Starting ZIP extraction - data size: \(data.count) bytes")
+
+ return try extractUsingCustomParser(data: data)
+ }
+
+ private static func extractUsingCustomParser(data: Data) throws -> ImportResult {
+ var jsonContent = ""
+ var metadataContent = ""
+ var importedImagePaths: [String: String] = [:]
+
+ let zipEntries: [ZipEntry]
+ do {
+ zipEntries = try parseZipFile(data: data)
+ print("Successfully parsed ZIP file with \(zipEntries.count) entries")
+ } catch {
+ print("Failed to parse ZIP file: \(error)")
+ print(
+ "ZIP data header: \(data.prefix(20).map { String(format: "%02X", $0) }.joined(separator: " "))"
+ )
+ throw NSError(
+ domain: "ImportError", code: 1,
+ userInfo: [
+ NSLocalizedDescriptionKey:
+ "Failed to parse ZIP file: \(error.localizedDescription). This may be due to incompatibility with the ZIP format."
+ ]
+ )
+ }
+
+ print("Found \(zipEntries.count) entries in ZIP file:")
+ for entry in zipEntries {
+ print(" - \(entry.filename) (size: \(entry.data.count) bytes)")
+ }
+
+ for entry in zipEntries {
+ switch entry.filename {
+ case METADATA_FILENAME:
+ metadataContent = String(data: entry.data, encoding: .utf8) ?? ""
+ print("Found metadata: \(metadataContent.prefix(100))...")
+
+ case DATA_JSON_FILENAME:
+ jsonContent = String(data: entry.data, encoding: .utf8) ?? ""
+ print("Found data.json with \(jsonContent.count) characters")
+ if jsonContent.isEmpty {
+ print("WARNING: data.json is empty!")
+ } else {
+ print("data.json preview: \(jsonContent.prefix(200))...")
+ }
+
+ default:
+ if entry.filename.hasPrefix("\(IMAGES_DIR_NAME)/") && !entry.filename.hasSuffix("/")
+ {
+ let originalFilename = String(
+ entry.filename.dropFirst("\(IMAGES_DIR_NAME)/".count))
+
+ do {
+
+ let documentsURL = FileManager.default.urls(
+ for: .documentDirectory, in: .userDomainMask
+ ).first!
+ let imagesDir = documentsURL.appendingPathComponent("images")
+ try FileManager.default.createDirectory(
+ at: imagesDir, withIntermediateDirectories: true)
+
+ let newImageURL = imagesDir.appendingPathComponent(originalFilename)
+ try entry.data.write(to: newImageURL)
+
+ importedImagePaths[originalFilename] = newImageURL.path
+ print(
+ "Successfully imported image: \(originalFilename) -> \(newImageURL.path)"
+ )
+ } catch {
+ print("Failed to import image \(originalFilename): \(error)")
+ }
+ }
+ }
+ }
+
+ guard !jsonContent.isEmpty else {
+ print("ERROR: data.json not found or empty")
+ print("Available files in ZIP:")
+ for entry in zipEntries {
+ print(" - \(entry.filename)")
+ }
+ throw NSError(
+ domain: "ImportError", code: 1,
+ userInfo: [
+ NSLocalizedDescriptionKey:
+ "Invalid ZIP file: data.json not found or empty. Found files: \(zipEntries.map { $0.filename }.joined(separator: ", "))"
+ ]
+ )
+ }
+
+ print("Import extraction completed: \(importedImagePaths.count) images processed")
+
+ return ImportResult(
+ jsonData: jsonContent.data(using: .utf8) ?? Data(), imagePathMapping: importedImagePaths
+ )
+ }
+
+ private static func createMetadata(
+ exportData: ClimbDataExport,
+ referencedImagePaths: Set
+ ) -> String {
+ return """
+ OpenClimb Export Metadata
+ =======================
+ Export Date: \(exportData.exportedAt)
+ Gyms: \(exportData.gyms.count)
+ Problems: \(exportData.problems.count)
+ Sessions: \(exportData.sessions.count)
+ Attempts: \(exportData.attempts.count)
+ Referenced Images: \(referencedImagePaths.count)
+ Format: ZIP with embedded JSON data and images
+ """
+ }
+
+ private static func addFileToZip(
+ filename: String,
+ fileData: Data,
+ zipData: inout Data,
+ fileEntries: inout [(name: String, data: Data, offset: UInt32)],
+ currentOffset: inout UInt32
+ ) throws {
+
+ let localHeader = createLocalFileHeader(filename: filename, fileData: fileData)
+ let headerOffset = currentOffset
+
+ zipData.append(localHeader)
+ zipData.append(fileData)
+
+ fileEntries.append((name: filename, data: fileData, offset: headerOffset))
+
+ currentOffset += UInt32(localHeader.count + fileData.count)
+ }
+
+ private static func createLocalFileHeader(filename: String, fileData: Data) -> Data {
+ var header = Data()
+
+ header.append(Data([0x50, 0x4b, 0x03, 0x04]))
+
+ header.append(Data([0x14, 0x00]))
+
+ header.append(Data([0x00, 0x00]))
+
+ header.append(Data([0x00, 0x00]))
+
+ // Last mod file time & date (use current time)
+ let dosTime = getDosDateTime()
+ header.append(dosTime)
+
+ let crc = calculateCRC32(data: fileData)
+ header.append(withUnsafeBytes(of: crc.littleEndian) { Data($0) })
+
+ // Compressed size (same as uncompressed since no compression)
+ let compressedSize = UInt32(fileData.count)
+ header.append(withUnsafeBytes(of: compressedSize.littleEndian) { Data($0) })
+
+ let uncompressedSize = UInt32(fileData.count)
+ header.append(withUnsafeBytes(of: uncompressedSize.littleEndian) { Data($0) })
+
+ let filenameData = filename.data(using: .utf8) ?? Data()
+ let filenameLength = UInt16(filenameData.count)
+ header.append(withUnsafeBytes(of: filenameLength.littleEndian) { Data($0) })
+
+ header.append(Data([0x00, 0x00]))
+
+ header.append(filenameData)
+
+ return header
+ }
+
+ private static func createCentralDirectoryEntry(
+ filename: String,
+ fileData: Data,
+ localHeaderOffset: UInt32
+ ) -> Data {
+ var entry = Data()
+
+ entry.append(Data([0x50, 0x4b, 0x01, 0x02]))
+
+ entry.append(Data([0x14, 0x00]))
+
+ entry.append(Data([0x14, 0x00]))
+
+ entry.append(Data([0x00, 0x00]))
+
+ entry.append(Data([0x00, 0x00]))
+
+ // Last mod file time & date
+ let dosTime = getDosDateTime()
+ entry.append(dosTime)
+
+ let crc = calculateCRC32(data: fileData)
+ entry.append(withUnsafeBytes(of: crc.littleEndian) { Data($0) })
+
+ let compressedSize = UInt32(fileData.count)
+ entry.append(withUnsafeBytes(of: compressedSize.littleEndian) { Data($0) })
+
+ let uncompressedSize = UInt32(fileData.count)
+ entry.append(withUnsafeBytes(of: uncompressedSize.littleEndian) { Data($0) })
+
+ let filenameData = filename.data(using: .utf8) ?? Data()
+ let filenameLength = UInt16(filenameData.count)
+ entry.append(withUnsafeBytes(of: filenameLength.littleEndian) { Data($0) })
+
+ entry.append(Data([0x00, 0x00]))
+
+ // File comment length
+ entry.append(Data([0x00, 0x00]))
+
+ entry.append(Data([0x00, 0x00]))
+
+ entry.append(Data([0x00, 0x00]))
+
+ entry.append(Data([0x00, 0x00, 0x00, 0x00]))
+
+ // Relative offset of local header
+ entry.append(withUnsafeBytes(of: localHeaderOffset.littleEndian) { Data($0) })
+
+ entry.append(filenameData)
+
+ return entry
+ }
+
+ private static func createEndOfCentralDirectory(
+ numEntries: UInt16,
+ centralDirSize: UInt32,
+ centralDirOffset: UInt32
+ ) -> Data {
+ var endRecord = Data()
+
+ endRecord.append(Data([0x50, 0x4b, 0x05, 0x06]))
+
+ endRecord.append(Data([0x00, 0x00]))
+
+ // Number of the disk with the start of the central directory
+ endRecord.append(Data([0x00, 0x00]))
+
+ // Total number of entries in the central directory on this disk
+ endRecord.append(withUnsafeBytes(of: numEntries.littleEndian) { Data($0) })
+
+ // Total number of entries in the central directory
+ endRecord.append(withUnsafeBytes(of: numEntries.littleEndian) { Data($0) })
+
+ endRecord.append(withUnsafeBytes(of: centralDirSize.littleEndian) { Data($0) })
+
+ // Offset of start of central directory
+ endRecord.append(withUnsafeBytes(of: centralDirOffset.littleEndian) { Data($0) })
+
+ // ZIP file comment length
+ endRecord.append(Data([0x00, 0x00]))
+
+ return endRecord
+ }
+
+ private static func getDosDateTime() -> Data {
+ let date = Date()
+ let calendar = Calendar.current
+ let components = calendar.dateComponents(
+ [.year, .month, .day, .hour, .minute, .second], from: date)
+
+ let year = UInt16(max(1980, components.year ?? 1980) - 1980)
+ let month = UInt16(components.month ?? 1)
+ let day = UInt16(components.day ?? 1)
+ let hour = UInt16(components.hour ?? 0)
+ let minute = UInt16(components.minute ?? 0)
+ let second = UInt16((components.second ?? 0) / 2)
+
+ let dosDate = (year << 9) | (month << 5) | day
+ let dosTime = (hour << 11) | (minute << 5) | second
+
+ var data = Data()
+ data.append(withUnsafeBytes(of: dosTime.littleEndian) { Data($0) })
+ data.append(withUnsafeBytes(of: dosDate.littleEndian) { Data($0) })
+ return data
+ }
+
+ private static func calculateCRC32(data: Data) -> UInt32 {
+ let polynomial: UInt32 = 0xEDB8_8320
+ var crc: UInt32 = 0xFFFF_FFFF
+
+ for byte in data {
+ crc ^= UInt32(byte)
+ for _ in 0..<8 {
+ if crc & 1 != 0 {
+ crc = (crc >> 1) ^ polynomial
+ } else {
+ crc >>= 1
+ }
+ }
+ }
+
+ return ~crc
+ }
+
+ private static func parseZipFile(data: Data) throws -> [ZipEntry] {
+
+ var endOfCentralDirOffset = -1
+ let signature = Data([0x50, 0x4b, 0x05, 0x06])
+
+ for i in stride(from: data.count - 22, through: 0, by: -1) {
+ if data.subdata(in: i..= 0 else {
+ throw NSError(
+ domain: "ZipError", code: 1,
+ userInfo: [NSLocalizedDescriptionKey: "End of central directory not found"])
+ }
+
+ let endRecord = data.subdata(in: endOfCentralDirOffset.. ZipEntry {
+ guard offset + 46 <= data.count else {
+ throw NSError(
+ domain: "ZipError", code: 1,
+ userInfo: [NSLocalizedDescriptionKey: "Invalid central directory entry"])
+ }
+
+ let entryData = data.subdata(in: offset.. Data {
+ let headerOffset = Int(entry.localHeaderOffset)
+ guard headerOffset + 30 <= data.count else {
+ throw NSError(
+ domain: "ZipError", code: 1,
+ userInfo: [NSLocalizedDescriptionKey: "Invalid local header offset"])
+ }
+
+ let headerData = data.subdata(in: headerOffset.. Data {
+ let buffer = UnsafeMutablePointer.allocate(capacity: 1024 * 1024)
+ defer { buffer.deallocate() }
+
+ var decompressedData = Data()
+
+ try data.withUnsafeBytes { bytes in
+ var stream = z_stream()
+ stream.next_in = UnsafeMutablePointer(
+ mutating: bytes.bindMemory(to: UInt8.self).baseAddress)
+ stream.avail_in = UInt32(data.count)
+
+ let initResult = inflateInit2_(
+ &stream, -15, ZLIB_VERSION, Int32(MemoryLayout.size))
+ guard initResult == Z_OK else {
+ throw NSError(
+ domain: "ZipError", code: 1,
+ userInfo: [
+ NSLocalizedDescriptionKey: "Failed to initialize deflate decompression"
+ ])
+ }
+
+ defer { inflateEnd(&stream) }
+
+ var result: Int32
+
+ repeat {
+ stream.next_out = buffer
+ stream.avail_out = 1024 * 1024
+
+ result = inflate(&stream, Z_NO_FLUSH)
+
+ if result != Z_OK && result != Z_STREAM_END {
+ throw NSError(
+ domain: "ZipError", code: 1,
+ userInfo: [
+ NSLocalizedDescriptionKey: "Decompression failed with code: \(result)"
+ ])
+ }
+
+ let bytesDecompressed = 1024 * 1024 - Int(stream.avail_out)
+ if bytesDecompressed > 0 {
+ decompressedData.append(buffer, count: bytesDecompressed)
+ }
+ } while result != Z_STREAM_END
+ }
+
+ return decompressedData
+ }
+}
+
+struct ZipEntry {
+ let filename: String
+ let data: Data
+ let localHeaderOffset: UInt32
+ let compressedSize: UInt32
+ let uncompressedSize: UInt32
+ let compressionMethod: UInt16
+
+ init(filename: String, data: Data) {
+ self.filename = filename
+ self.data = data
+ self.localHeaderOffset = 0
+ self.compressedSize = 0
+ self.uncompressedSize = 0
+ self.compressionMethod = 0
+ }
+
+ init(
+ filename: String, localHeaderOffset: UInt32, compressedSize: UInt32,
+ uncompressedSize: UInt32 = 0, compressionMethod: UInt16 = 0
+ ) {
+ self.filename = filename
+ self.data = Data()
+ self.localHeaderOffset = localHeaderOffset
+ self.compressedSize = compressedSize
+ self.uncompressedSize = uncompressedSize
+ self.compressionMethod = compressionMethod
+ }
+}
+
+struct ImportResult {
+ let jsonData: Data
+ let imagePathMapping: [String: String]
+}
diff --git a/ios/OpenClimb/ViewModels/ClimbingDataManager.swift b/ios/OpenClimb/ViewModels/ClimbingDataManager.swift
new file mode 100644
index 0000000..e6b6d7f
--- /dev/null
+++ b/ios/OpenClimb/ViewModels/ClimbingDataManager.swift
@@ -0,0 +1,834 @@
+//
+// ClimbingDataManager.swift
+// OpenClimb
+//
+// Created by OpenClimb on 2025-01-17.
+//
+
+import Combine
+import Foundation
+import SwiftUI
+import UniformTypeIdentifiers
+
+@MainActor
+class ClimbingDataManager: ObservableObject {
+
+ @Published var gyms: [Gym] = []
+ @Published var problems: [Problem] = []
+ @Published var sessions: [ClimbSession] = []
+ @Published var attempts: [Attempt] = []
+ @Published var activeSession: ClimbSession?
+ @Published var isLoading = false
+ @Published var errorMessage: String?
+ @Published var successMessage: String?
+
+ private let userDefaults = UserDefaults.standard
+ private let encoder = JSONEncoder()
+ private let decoder = JSONDecoder()
+
+ private enum Keys {
+ static let gyms = "openclimb_gyms"
+ static let problems = "openclimb_problems"
+ static let sessions = "openclimb_sessions"
+ static let attempts = "openclimb_attempts"
+ static let activeSession = "openclimb_active_session"
+ }
+
+ init() {
+ loadAllData()
+ }
+
+ private func loadAllData() {
+ loadGyms()
+ loadProblems()
+ loadSessions()
+ loadAttempts()
+ loadActiveSession()
+ }
+
+ private func loadGyms() {
+ if let data = userDefaults.data(forKey: Keys.gyms),
+ let loadedGyms = try? decoder.decode([Gym].self, from: data)
+ {
+ self.gyms = loadedGyms
+ }
+ }
+
+ private func loadProblems() {
+ if let data = userDefaults.data(forKey: Keys.problems),
+ let loadedProblems = try? decoder.decode([Problem].self, from: data)
+ {
+ self.problems = loadedProblems
+ }
+ }
+
+ private func loadSessions() {
+ if let data = userDefaults.data(forKey: Keys.sessions),
+ let loadedSessions = try? decoder.decode([ClimbSession].self, from: data)
+ {
+ self.sessions = loadedSessions
+ }
+ }
+
+ private func loadAttempts() {
+ if let data = userDefaults.data(forKey: Keys.attempts),
+ let loadedAttempts = try? decoder.decode([Attempt].self, from: data)
+ {
+ self.attempts = loadedAttempts
+ }
+ }
+
+ private func loadActiveSession() {
+ if let data = userDefaults.data(forKey: Keys.activeSession),
+ let loadedActiveSession = try? decoder.decode(ClimbSession.self, from: data)
+ {
+ self.activeSession = loadedActiveSession
+ }
+ }
+
+ private func saveGyms() {
+ if let data = try? encoder.encode(gyms) {
+ userDefaults.set(data, forKey: Keys.gyms)
+ }
+ }
+
+ private func saveProblems() {
+ if let data = try? encoder.encode(problems) {
+ userDefaults.set(data, forKey: Keys.problems)
+ }
+ }
+
+ private func saveSessions() {
+ if let data = try? encoder.encode(sessions) {
+ userDefaults.set(data, forKey: Keys.sessions)
+ }
+ }
+
+ private func saveAttempts() {
+ if let data = try? encoder.encode(attempts) {
+ userDefaults.set(data, forKey: Keys.attempts)
+ }
+ }
+
+ private func saveActiveSession() {
+ if let activeSession = activeSession,
+ let data = try? encoder.encode(activeSession)
+ {
+ userDefaults.set(data, forKey: Keys.activeSession)
+ } else {
+ userDefaults.removeObject(forKey: Keys.activeSession)
+ }
+ }
+
+ func addGym(_ gym: Gym) {
+ gyms.append(gym)
+ saveGyms()
+ successMessage = "Gym added successfully"
+ clearMessageAfterDelay()
+ }
+
+ func updateGym(_ gym: Gym) {
+ if let index = gyms.firstIndex(where: { $0.id == gym.id }) {
+ gyms[index] = gym
+ saveGyms()
+ successMessage = "Gym updated successfully"
+ clearMessageAfterDelay()
+ }
+ }
+
+ func deleteGym(_ gym: Gym) {
+ // Delete associated problems and their attempts first
+ let problemsToDelete = problems.filter { $0.gymId == gym.id }
+ for problem in problemsToDelete {
+ deleteProblem(problem)
+ }
+
+ // Delete associated sessions and their attempts
+ let sessionsToDelete = sessions.filter { $0.gymId == gym.id }
+ for session in sessionsToDelete {
+ deleteSession(session)
+ }
+
+ // Delete the gym
+ gyms.removeAll { $0.id == gym.id }
+ saveGyms()
+ successMessage = "Gym deleted successfully"
+ clearMessageAfterDelay()
+ }
+
+ func gym(withId id: UUID) -> Gym? {
+ return gyms.first { $0.id == id }
+ }
+
+ func addProblem(_ problem: Problem) {
+ problems.append(problem)
+ saveProblems()
+ successMessage = "Problem added successfully"
+ clearMessageAfterDelay()
+ }
+
+ func updateProblem(_ problem: Problem) {
+ if let index = problems.firstIndex(where: { $0.id == problem.id }) {
+ problems[index] = problem
+ saveProblems()
+ successMessage = "Problem updated successfully"
+ clearMessageAfterDelay()
+ }
+ }
+
+ func deleteProblem(_ problem: Problem) {
+ // Delete associated attempts first
+ attempts.removeAll { $0.problemId == problem.id }
+ saveAttempts()
+
+ // Delete the problem
+ problems.removeAll { $0.id == problem.id }
+ saveProblems()
+ successMessage = "Problem deleted successfully"
+ clearMessageAfterDelay()
+ }
+
+ func problem(withId id: UUID) -> Problem? {
+ return problems.first { $0.id == id }
+ }
+
+ func problems(forGym gymId: UUID) -> [Problem] {
+ return problems.filter { $0.gymId == gymId }
+ }
+
+ func activeProblems(forGym gymId: UUID) -> [Problem] {
+ return problems.filter { $0.gymId == gymId && $0.isActive }
+ }
+
+ func startSession(gymId: UUID, notes: String? = nil) {
+
+ if let currentActive = activeSession {
+ endSession(currentActive.id)
+ }
+
+ let newSession = ClimbSession(gymId: gymId, notes: notes)
+ activeSession = newSession
+ sessions.append(newSession)
+
+ saveActiveSession()
+ saveSessions()
+
+ successMessage = "Session started successfully"
+ clearMessageAfterDelay()
+ }
+
+ func endSession(_ sessionId: UUID) {
+ if let session = sessions.first(where: { $0.id == sessionId && $0.status == .active }),
+ let index = sessions.firstIndex(where: { $0.id == sessionId })
+ {
+
+ let completedSession = session.completed()
+ sessions[index] = completedSession
+
+ if activeSession?.id == sessionId {
+ activeSession = nil
+ }
+
+ saveActiveSession()
+ saveSessions()
+ successMessage = "Session completed successfully"
+ clearMessageAfterDelay()
+ }
+ }
+
+ func updateSession(_ session: ClimbSession) {
+ if let index = sessions.firstIndex(where: { $0.id == session.id }) {
+ sessions[index] = session
+
+ if activeSession?.id == session.id {
+ activeSession = session
+ saveActiveSession()
+ }
+
+ saveSessions()
+ successMessage = "Session updated successfully"
+ clearMessageAfterDelay()
+ }
+ }
+
+ func deleteSession(_ session: ClimbSession) {
+ // Delete associated attempts first
+ attempts.removeAll { $0.sessionId == session.id }
+ saveAttempts()
+
+ // Remove from active session if it's the current one
+ if activeSession?.id == session.id {
+ activeSession = nil
+ saveActiveSession()
+ }
+
+ // Delete the session
+ sessions.removeAll { $0.id == session.id }
+ saveSessions()
+ successMessage = "Session deleted successfully"
+ clearMessageAfterDelay()
+ }
+
+ func session(withId id: UUID) -> ClimbSession? {
+ return sessions.first { $0.id == id }
+ }
+
+ func sessions(forGym gymId: UUID) -> [ClimbSession] {
+ return sessions.filter { $0.gymId == gymId }
+ }
+
+ func getLastUsedGym() -> Gym? {
+ let recentSessions = sessions.sorted { $0.date > $1.date }
+ guard let lastSession = recentSessions.first else { return nil }
+ return gym(withId: lastSession.gymId)
+ }
+
+ func addAttempt(_ attempt: Attempt) {
+ attempts.append(attempt)
+ saveAttempts()
+
+ successMessage = "Attempt logged successfully"
+ clearMessageAfterDelay()
+ }
+
+ func updateAttempt(_ attempt: Attempt) {
+ if let index = attempts.firstIndex(where: { $0.id == attempt.id }) {
+ attempts[index] = attempt
+ saveAttempts()
+ successMessage = "Attempt updated successfully"
+ clearMessageAfterDelay()
+ }
+ }
+
+ func deleteAttempt(_ attempt: Attempt) {
+ attempts.removeAll { $0.id == attempt.id }
+ saveAttempts()
+ successMessage = "Attempt deleted successfully"
+ clearMessageAfterDelay()
+ }
+
+ func attempts(forSession sessionId: UUID) -> [Attempt] {
+ return attempts.filter { $0.sessionId == sessionId }.sorted { $0.timestamp < $1.timestamp }
+ }
+
+ func attempts(forProblem problemId: UUID) -> [Attempt] {
+ return attempts.filter { $0.problemId == problemId }.sorted { $0.timestamp > $1.timestamp }
+ }
+
+ func successfulAttempts(forProblem problemId: UUID) -> [Attempt] {
+ return attempts.filter { $0.problemId == problemId && $0.result.isSuccessful }
+ }
+
+ func completedSessions() -> [ClimbSession] {
+ return sessions.filter { $0.status == .completed }
+ }
+
+ func totalAttempts() -> Int {
+ return attempts.count
+ }
+
+ func successfulAttempts() -> Int {
+ return attempts.filter { $0.result.isSuccessful }.count
+ }
+
+ func completedProblems() -> Int {
+ let completedProblemIds = Set(
+ attempts.filter { $0.result.isSuccessful }.map { $0.problemId })
+ return completedProblemIds.count
+ }
+
+ func favoriteGym() -> Gym? {
+ let gymSessionCounts = Dictionary(grouping: sessions, by: { $0.gymId })
+ .mapValues { $0.count }
+
+ guard let mostUsedGymId = gymSessionCounts.max(by: { $0.value < $1.value })?.key else {
+ return nil
+ }
+
+ return gym(withId: mostUsedGymId)
+ }
+
+ func resetAllData() {
+ gyms.removeAll()
+ problems.removeAll()
+ sessions.removeAll()
+ attempts.removeAll()
+ activeSession = nil
+
+ userDefaults.removeObject(forKey: Keys.gyms)
+ userDefaults.removeObject(forKey: Keys.problems)
+ userDefaults.removeObject(forKey: Keys.sessions)
+ userDefaults.removeObject(forKey: Keys.attempts)
+ userDefaults.removeObject(forKey: Keys.activeSession)
+
+ successMessage = "All data has been reset"
+ clearMessageAfterDelay()
+ }
+
+ func exportData() -> Data? {
+ do {
+ let dateFormatter = DateFormatter()
+ dateFormatter.dateFormat = "yyyy-MM-dd'T'HH:mm:ss.SSSSSS"
+
+ let exportData = ClimbDataExport(
+ exportedAt: dateFormatter.string(from: Date()),
+ gyms: gyms.map { AndroidGym(from: $0) },
+ problems: problems.map { AndroidProblem(from: $0) },
+ sessions: sessions.map { AndroidClimbSession(from: $0) },
+ attempts: attempts.map { AndroidAttempt(from: $0) }
+ )
+
+ // Collect referenced image paths
+ let referencedImagePaths = collectReferencedImagePaths()
+
+ return try ZipUtils.createExportZip(
+ exportData: exportData,
+ referencedImagePaths: referencedImagePaths
+ )
+ } catch {
+ setError("Export failed: \(error.localizedDescription)")
+ return nil
+ }
+ }
+
+ func importData(from data: Data) throws {
+ do {
+ let importResult = try ZipUtils.extractImportZip(data: data)
+
+ let dateFormatter = DateFormatter()
+ dateFormatter.dateFormat = "yyyy-MM-dd'T'HH:mm:ss.SSSSSS"
+
+ let decoder = JSONDecoder()
+ decoder.dateDecodingStrategy = .custom { decoder in
+ let container = try decoder.singleValueContainer()
+ let dateString = try container.decode(String.self)
+
+ if let date = ISO8601DateFormatter().date(from: dateString) {
+ return date
+ }
+
+ if let date = dateFormatter.date(from: dateString) {
+ return date
+ }
+
+ return Date()
+ }
+
+ print("Raw JSON content preview:")
+ print(String(decoding: importResult.jsonData.prefix(500), as: UTF8.self) + "...")
+
+ let importData = try decoder.decode(ClimbDataExport.self, from: importResult.jsonData)
+
+ print("Successfully decoded import data:")
+ print("- Gyms: \(importData.gyms.count)")
+ print("- Problems: \(importData.problems.count)")
+ print("- Sessions: \(importData.sessions.count)")
+ print("- Attempts: \(importData.attempts.count)")
+
+ try validateImportData(importData)
+
+ resetAllData()
+
+ let updatedProblems = updateProblemImagePaths(
+ problems: importData.problems,
+ imagePathMapping: importResult.imagePathMapping
+ )
+
+ self.gyms = importData.gyms.map { $0.toGym() }
+ self.problems = updatedProblems.map { $0.toProblem() }
+ self.sessions = importData.sessions.map { $0.toClimbSession() }
+ self.attempts = importData.attempts.map { $0.toAttempt() }
+
+ saveGyms()
+ saveProblems()
+ saveSessions()
+ saveAttempts()
+
+ successMessage =
+ "Data imported successfully with \(importResult.imagePathMapping.count) images"
+ clearMessageAfterDelay()
+ } catch {
+ setError("Import failed: \(error.localizedDescription)")
+ throw error
+ }
+ }
+
+ func clearMessages() {
+ errorMessage = nil
+ successMessage = nil
+ }
+
+ private func clearMessageAfterDelay() {
+ Task {
+ try? await Task.sleep(nanoseconds: 3_000_000_000) // 3 seconds
+ successMessage = nil
+ errorMessage = nil
+ }
+ }
+
+ func setError(_ message: String) {
+ errorMessage = message
+ clearMessageAfterDelay()
+ }
+}
+
+struct ClimbDataExport: Codable {
+ let exportedAt: String
+ let gyms: [AndroidGym]
+ let problems: [AndroidProblem]
+ let sessions: [AndroidClimbSession]
+ let attempts: [AndroidAttempt]
+
+ init(
+ exportedAt: String, gyms: [AndroidGym], problems: [AndroidProblem],
+ sessions: [AndroidClimbSession], attempts: [AndroidAttempt]
+ ) {
+ self.exportedAt = exportedAt
+ self.gyms = gyms
+ self.problems = problems
+ self.sessions = sessions
+ self.attempts = attempts
+ }
+}
+
+struct AndroidGym: Codable {
+ let id: String
+ let name: String
+ let location: String?
+ let supportedClimbTypes: [ClimbType]
+ let difficultySystems: [DifficultySystem]
+ let notes: String?
+ let createdAt: String
+ let updatedAt: String
+
+ init(from gym: Gym) {
+ self.id = gym.id.uuidString
+ self.name = gym.name
+ self.location = gym.location
+ self.supportedClimbTypes = gym.supportedClimbTypes
+ self.difficultySystems = gym.difficultySystems
+ self.notes = gym.notes
+ let formatter = DateFormatter()
+ formatter.dateFormat = "yyyy-MM-dd'T'HH:mm:ss.SSSSSS"
+ self.createdAt = formatter.string(from: gym.createdAt)
+ self.updatedAt = formatter.string(from: gym.updatedAt)
+ }
+
+ init(
+ id: String, name: String, location: String?, supportedClimbTypes: [ClimbType],
+ difficultySystems: [DifficultySystem], notes: String?, createdAt: String, updatedAt: String
+ ) {
+ self.id = id
+ self.name = name
+ self.location = location
+ self.supportedClimbTypes = supportedClimbTypes
+ self.difficultySystems = difficultySystems
+ self.notes = notes
+ self.createdAt = createdAt
+ self.updatedAt = updatedAt
+ }
+
+ func toGym() -> Gym {
+ let formatter = DateFormatter()
+ formatter.dateFormat = "yyyy-MM-dd'T'HH:mm:ss.SSSSSS"
+
+ let gymId = UUID(uuidString: id) ?? UUID()
+ let createdDate = formatter.date(from: createdAt) ?? Date()
+ let updatedDate = formatter.date(from: updatedAt) ?? Date()
+
+ return Gym.fromImport(
+ id: gymId,
+ name: name,
+ location: location,
+ supportedClimbTypes: supportedClimbTypes,
+ difficultySystems: difficultySystems,
+ customDifficultyGrades: [],
+ notes: notes,
+ createdAt: createdDate,
+ updatedAt: updatedDate
+ )
+ }
+}
+
+struct AndroidProblem: Codable {
+ let id: String
+ let gymId: String
+ let name: String?
+ let description: String?
+ let climbType: ClimbType
+ let difficulty: DifficultyGrade
+ let imagePaths: [String]?
+ let createdAt: String
+ let updatedAt: String
+
+ init(from problem: Problem) {
+ self.id = problem.id.uuidString
+ self.gymId = problem.gymId.uuidString
+ self.name = problem.name
+ self.description = problem.description
+ self.climbType = problem.climbType
+ self.difficulty = problem.difficulty
+ self.imagePaths = problem.imagePaths.isEmpty ? nil : problem.imagePaths
+ let formatter = DateFormatter()
+ formatter.dateFormat = "yyyy-MM-dd'T'HH:mm:ss.SSSSSS"
+ self.createdAt = formatter.string(from: problem.createdAt)
+ self.updatedAt = formatter.string(from: problem.updatedAt)
+ }
+
+ init(
+ id: String, gymId: String, name: String?, description: String?, climbType: ClimbType,
+ difficulty: DifficultyGrade, imagePaths: [String]?, createdAt: String, updatedAt: String
+ ) {
+ self.id = id
+ self.gymId = gymId
+ self.name = name
+ self.description = description
+ self.climbType = climbType
+ self.difficulty = difficulty
+ self.imagePaths = imagePaths
+ self.createdAt = createdAt
+ self.updatedAt = updatedAt
+ }
+
+ func toProblem() -> Problem {
+ let formatter = DateFormatter()
+ formatter.dateFormat = "yyyy-MM-dd'T'HH:mm:ss.SSSSSS"
+
+ let problemId = UUID(uuidString: id) ?? UUID()
+ let preservedGymId = UUID(uuidString: gymId) ?? UUID()
+ let createdDate = formatter.date(from: createdAt) ?? Date()
+ let updatedDate = formatter.date(from: updatedAt) ?? Date()
+
+ return Problem.fromImport(
+ id: problemId,
+ gymId: preservedGymId,
+ name: name,
+ description: description,
+ climbType: climbType,
+ difficulty: difficulty,
+ setter: nil,
+ tags: [],
+ location: nil,
+ imagePaths: imagePaths ?? [],
+ isActive: true,
+ dateSet: nil,
+ notes: nil,
+ createdAt: createdDate,
+ updatedAt: updatedDate
+ )
+ }
+
+ func withUpdatedImagePaths(_ newImagePaths: [String]) -> AndroidProblem {
+ return AndroidProblem(
+ id: self.id,
+ gymId: self.gymId,
+ name: self.name,
+ description: self.description,
+ climbType: self.climbType,
+ difficulty: self.difficulty,
+ imagePaths: newImagePaths.isEmpty ? nil : newImagePaths,
+ createdAt: self.createdAt,
+ updatedAt: self.updatedAt
+ )
+ }
+}
+
+struct AndroidClimbSession: Codable {
+ let id: String
+ let gymId: String
+ let date: String
+ let startTime: String?
+ let endTime: String?
+ let duration: Int?
+ let status: SessionStatus
+ let createdAt: String
+ let updatedAt: String
+
+ init(from session: ClimbSession) {
+ self.id = session.id.uuidString
+ self.gymId = session.gymId.uuidString
+ let formatter = DateFormatter()
+ formatter.dateFormat = "yyyy-MM-dd'T'HH:mm:ss.SSSSSS"
+ self.date = formatter.string(from: session.date)
+ self.startTime = session.startTime != nil ? formatter.string(from: session.startTime!) : nil
+ self.endTime = session.endTime != nil ? formatter.string(from: session.endTime!) : nil
+ self.duration = session.duration
+ self.status = session.status
+ self.createdAt = formatter.string(from: session.createdAt)
+ self.updatedAt = formatter.string(from: session.updatedAt)
+ }
+
+ init(
+ id: String, gymId: String, date: String, startTime: String?, endTime: String?,
+ duration: Int?, status: SessionStatus, createdAt: String, updatedAt: String
+ ) {
+ self.id = id
+ self.gymId = gymId
+ self.date = date
+ self.startTime = startTime
+ self.endTime = endTime
+ self.duration = duration
+ self.status = status
+ self.createdAt = createdAt
+ self.updatedAt = updatedAt
+ }
+
+ func toClimbSession() -> ClimbSession {
+ let formatter = DateFormatter()
+ formatter.dateFormat = "yyyy-MM-dd'T'HH:mm:ss.SSSSSS"
+
+ // Preserve original IDs and dates
+ let sessionId = UUID(uuidString: id) ?? UUID()
+ let preservedGymId = UUID(uuidString: gymId) ?? UUID()
+ let sessionDate = formatter.date(from: date) ?? Date()
+ let sessionStartTime = startTime != nil ? formatter.date(from: startTime!) : nil
+ let sessionEndTime = endTime != nil ? formatter.date(from: endTime!) : nil
+ let createdDate = formatter.date(from: createdAt) ?? Date()
+ let updatedDate = formatter.date(from: updatedAt) ?? Date()
+
+ return ClimbSession.fromImport(
+ id: sessionId,
+ gymId: preservedGymId,
+ date: sessionDate,
+ startTime: sessionStartTime,
+ endTime: sessionEndTime,
+ duration: duration,
+ status: status,
+ notes: nil,
+ createdAt: createdDate,
+ updatedAt: updatedDate
+ )
+ }
+}
+
+struct AndroidAttempt: Codable {
+ let id: String
+ let sessionId: String
+ let problemId: String
+ let result: AttemptResult
+ let highestHold: String?
+ let notes: String?
+ let duration: Int?
+ let restTime: Int?
+ let timestamp: String
+ let createdAt: String
+
+ init(from attempt: Attempt) {
+ self.id = attempt.id.uuidString
+ self.sessionId = attempt.sessionId.uuidString
+ self.problemId = attempt.problemId.uuidString
+ self.result = attempt.result
+ self.highestHold = attempt.highestHold
+ self.notes = attempt.notes
+ self.duration = attempt.duration
+ self.restTime = attempt.restTime
+ let formatter = DateFormatter()
+ formatter.dateFormat = "yyyy-MM-dd'T'HH:mm:ss.SSSSSS"
+ self.timestamp = formatter.string(from: attempt.timestamp)
+ self.createdAt = formatter.string(from: attempt.createdAt)
+ }
+
+ init(
+ id: String, sessionId: String, problemId: String, result: AttemptResult,
+ highestHold: String?, notes: String?, duration: Int?, restTime: Int?,
+ timestamp: String, createdAt: String
+ ) {
+ self.id = id
+ self.sessionId = sessionId
+ self.problemId = problemId
+ self.result = result
+ self.highestHold = highestHold
+ self.notes = notes
+ self.duration = duration
+ self.restTime = restTime
+ self.timestamp = timestamp
+ self.createdAt = createdAt
+ }
+
+ func toAttempt() -> Attempt {
+ let formatter = DateFormatter()
+ formatter.dateFormat = "yyyy-MM-dd'T'HH:mm:ss.SSSSSS"
+
+ let attemptId = UUID(uuidString: id) ?? UUID()
+ let preservedSessionId = UUID(uuidString: sessionId) ?? UUID()
+ let preservedProblemId = UUID(uuidString: problemId) ?? UUID()
+ let attemptTimestamp = formatter.date(from: timestamp) ?? Date()
+ let createdDate = formatter.date(from: createdAt) ?? Date()
+
+ return Attempt.fromImport(
+ id: attemptId,
+ sessionId: preservedSessionId,
+ problemId: preservedProblemId,
+ result: result,
+ highestHold: highestHold,
+ notes: notes,
+ duration: duration,
+ restTime: restTime,
+ timestamp: attemptTimestamp,
+ createdAt: createdDate
+ )
+ }
+}
+
+// MARK: - Helper Functions
+extension ClimbingDataManager {
+ private func collectReferencedImagePaths() -> Set {
+ var imagePaths = Set()
+ for problem in problems {
+ imagePaths.formUnion(problem.imagePaths)
+ }
+ return imagePaths
+ }
+
+ private func updateProblemImagePaths(
+ problems: [AndroidProblem],
+ imagePathMapping: [String: String]
+ ) -> [AndroidProblem] {
+ return problems.map { problem in
+ let updatedImagePaths = (problem.imagePaths ?? []).compactMap { oldPath in
+ let fileName = URL(fileURLWithPath: oldPath).lastPathComponent
+ return imagePathMapping[fileName]
+ }
+ return problem.withUpdatedImagePaths(updatedImagePaths)
+ }
+ }
+
+ private func validateImportData(_ importData: ClimbDataExport) throws {
+ if importData.gyms.isEmpty {
+ throw NSError(
+ domain: "ImportError", code: 1,
+ userInfo: [NSLocalizedDescriptionKey: "Import data is invalid: no gyms found"])
+ }
+ }
+}
+
+// MARK: - Preview Helper
+extension ClimbingDataManager {
+ static var preview: ClimbingDataManager {
+ let manager = ClimbingDataManager()
+
+ let sampleGym = Gym(
+ name: "Sample Climbing Gym",
+ location: "123 Rock St, Boulder, CO",
+ supportedClimbTypes: [.boulder, .rope],
+ difficultySystems: [.vScale, .yds]
+ )
+
+ manager.gyms = [sampleGym]
+
+ let sampleProblem = Problem(
+ gymId: sampleGym.id,
+ name: "Crimpy Overhang",
+ description: "Technical overhang with small holds",
+ climbType: .boulder,
+ difficulty: DifficultyGrade(system: .vScale, grade: "V4"),
+ setter: "John Doe",
+ tags: ["technical", "overhang"],
+ location: "Cave area"
+ )
+
+ manager.problems = [sampleProblem]
+
+ return manager
+ }
+}
diff --git a/ios/OpenClimb/Views/AddEdit/AddAttemptView.swift b/ios/OpenClimb/Views/AddEdit/AddAttemptView.swift
new file mode 100644
index 0000000..e20f355
--- /dev/null
+++ b/ios/OpenClimb/Views/AddEdit/AddAttemptView.swift
@@ -0,0 +1,554 @@
+//
+// AddAttemptView.swift
+// OpenClimb
+//
+// Created by OpenClimb on 2025-01-17.
+//
+
+import SwiftUI
+
+struct AddAttemptView: View {
+ let session: ClimbSession
+ let gym: Gym
+ @EnvironmentObject var dataManager: ClimbingDataManager
+ @Environment(\.dismiss) private var dismiss
+
+ @State private var selectedProblem: Problem?
+ @State private var selectedResult: AttemptResult = .fall
+ @State private var highestHold = ""
+ @State private var notes = ""
+ @State private var duration: Int = 0
+ @State private var restTime: Int = 0
+ @State private var showingCreateProblem = false
+
+ // New problem creation state
+ @State private var newProblemName = ""
+ @State private var newProblemGrade = ""
+ @State private var selectedClimbType: ClimbType = .boulder
+ @State private var selectedDifficultySystem: DifficultySystem = .vScale
+
+ private var activeProblems: [Problem] {
+ dataManager.activeProblems(forGym: gym.id)
+ }
+
+ private var availableClimbTypes: [ClimbType] {
+ gym.supportedClimbTypes
+ }
+
+ private var availableDifficultySystems: [DifficultySystem] {
+ DifficultySystem.systemsForClimbType(selectedClimbType).filter { system in
+ gym.difficultySystems.contains(system)
+ }
+ }
+
+ private var availableGrades: [String] {
+ selectedDifficultySystem.availableGrades
+ }
+
+ var body: some View {
+ NavigationView {
+ Form {
+ if !showingCreateProblem {
+ ProblemSelectionSection()
+ } else {
+ CreateProblemSection()
+ }
+
+ AttemptDetailsSection()
+ }
+ .navigationTitle("Add Attempt")
+ .navigationBarTitleDisplayMode(.inline)
+ .toolbar {
+ ToolbarItem(placement: .navigationBarLeading) {
+ Button("Cancel") {
+ dismiss()
+ }
+ }
+
+ ToolbarItem(placement: .navigationBarTrailing) {
+ Button("Add") {
+ saveAttempt()
+ }
+ .disabled(!canSave)
+ }
+ }
+ }
+ .onAppear {
+ setupInitialValues()
+ }
+ .onChange(of: selectedClimbType) { _ in
+ updateDifficultySystem()
+ }
+ .onChange(of: selectedDifficultySystem) { _ in
+ resetGradeIfNeeded()
+ }
+ }
+
+ @ViewBuilder
+ private func ProblemSelectionSection() -> some View {
+ Section("Select Problem") {
+ if activeProblems.isEmpty {
+ VStack(alignment: .leading, spacing: 12) {
+ Text("No active problems in this gym")
+ .foregroundColor(.secondary)
+
+ Button("Create New Problem") {
+ showingCreateProblem = true
+ }
+ .buttonStyle(.borderedProminent)
+ }
+ .padding(.vertical, 8)
+ } else {
+ ForEach(activeProblems, id: \.id) { problem in
+ ProblemSelectionRow(
+ problem: problem,
+ isSelected: selectedProblem?.id == problem.id
+ ) {
+ selectedProblem = problem
+ }
+ }
+
+ Button("Create New Problem") {
+ showingCreateProblem = true
+ }
+ .foregroundColor(.blue)
+ }
+ }
+ }
+
+ @ViewBuilder
+ private func CreateProblemSection() -> some View {
+ Section {
+ HStack {
+ Text("Create New Problem")
+ .font(.headline)
+
+ Spacer()
+
+ Button("Back") {
+ showingCreateProblem = false
+ }
+ .foregroundColor(.blue)
+ }
+ }
+
+ Section("Problem Details") {
+ TextField("Problem Name", text: $newProblemName)
+ }
+
+ Section("Climb Type") {
+ ForEach(availableClimbTypes, id: \.self) { climbType in
+ HStack {
+ Text(climbType.displayName)
+ Spacer()
+ if selectedClimbType == climbType {
+ Image(systemName: "checkmark.circle.fill")
+ .foregroundColor(.blue)
+ } else {
+ Image(systemName: "circle")
+ .foregroundColor(.gray)
+ }
+ }
+ .contentShape(Rectangle())
+ .onTapGesture {
+ selectedClimbType = climbType
+ }
+ }
+ }
+
+ Section("Difficulty") {
+ VStack(alignment: .leading, spacing: 12) {
+ Text("Difficulty System")
+ .font(.subheadline)
+ .fontWeight(.medium)
+
+ ForEach(availableDifficultySystems, id: \.self) { system in
+ HStack {
+ Text(system.displayName)
+ Spacer()
+ if selectedDifficultySystem == system {
+ Image(systemName: "checkmark.circle.fill")
+ .foregroundColor(.blue)
+ } else {
+ Image(systemName: "circle")
+ .foregroundColor(.gray)
+ }
+ }
+ .contentShape(Rectangle())
+ .onTapGesture {
+ selectedDifficultySystem = system
+ }
+ }
+ }
+
+ if selectedDifficultySystem == .custom {
+ TextField("Grade (Required)", text: $newProblemGrade)
+ .keyboardType(.numberPad)
+ } else {
+ VStack(alignment: .leading, spacing: 8) {
+ Text("Grade (Required)")
+ .font(.subheadline)
+ .fontWeight(.medium)
+
+ ScrollView(.horizontal, showsIndicators: false) {
+ LazyHStack(spacing: 8) {
+ ForEach(availableGrades, id: \.self) { grade in
+ Button(grade) {
+ newProblemGrade = grade
+ }
+ .buttonStyle(.bordered)
+ .controlSize(.small)
+ .tint(newProblemGrade == grade ? .blue : .gray)
+ }
+ }
+ .padding(.horizontal, 1)
+ }
+ }
+ }
+ }
+ }
+
+ @ViewBuilder
+ private func AttemptDetailsSection() -> some View {
+ Section("Attempt Result") {
+ ForEach(AttemptResult.allCases, id: \.self) { result in
+ HStack {
+ Text(result.displayName)
+ Spacer()
+ if selectedResult == result {
+ Image(systemName: "checkmark.circle.fill")
+ .foregroundColor(.blue)
+ } else {
+ Image(systemName: "circle")
+ .foregroundColor(.gray)
+ }
+ }
+ .contentShape(Rectangle())
+ .onTapGesture {
+ selectedResult = result
+ }
+ }
+ }
+
+ Section("Additional Details") {
+ TextField("Highest Hold (Optional)", text: $highestHold)
+
+ VStack(alignment: .leading, spacing: 8) {
+ Text("Notes (Optional)")
+ .font(.headline)
+
+ TextEditor(text: $notes)
+ .frame(minHeight: 80)
+ .padding(8)
+ .background(
+ RoundedRectangle(cornerRadius: 8)
+ .fill(.quaternary)
+ )
+ }
+
+ HStack {
+ Text("Duration (seconds)")
+ Spacer()
+ TextField("0", value: $duration, format: .number)
+ .keyboardType(.numberPad)
+ .textFieldStyle(.roundedBorder)
+ .frame(width: 80)
+ }
+
+ HStack {
+ Text("Rest Time (seconds)")
+ Spacer()
+ TextField("0", value: $restTime, format: .number)
+ .keyboardType(.numberPad)
+ .textFieldStyle(.roundedBorder)
+ .frame(width: 80)
+ }
+ }
+ }
+
+ private var canSave: Bool {
+ if showingCreateProblem {
+ return !newProblemGrade.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
+ } else {
+ return selectedProblem != nil
+ }
+ }
+
+ private func setupInitialValues() {
+ // Auto-select climb type if there's only one available
+ if gym.supportedClimbTypes.count == 1 {
+ selectedClimbType = gym.supportedClimbTypes.first!
+ }
+
+ updateDifficultySystem()
+ }
+
+ private func updateDifficultySystem() {
+ let available = availableDifficultySystems
+
+ if !available.contains(selectedDifficultySystem) {
+ selectedDifficultySystem = available.first ?? .custom
+ }
+
+ if available.count == 1 {
+ selectedDifficultySystem = available.first!
+ }
+ }
+
+ private func resetGradeIfNeeded() {
+ let availableGrades = selectedDifficultySystem.availableGrades
+ if !availableGrades.isEmpty && !availableGrades.contains(newProblemGrade) {
+ newProblemGrade = ""
+ }
+ }
+
+ private func saveAttempt() {
+ if showingCreateProblem {
+ let difficulty = DifficultyGrade(
+ system: selectedDifficultySystem, grade: newProblemGrade)
+
+ let newProblem = Problem(
+ gymId: gym.id,
+ name: newProblemName.isEmpty ? nil : newProblemName,
+ climbType: selectedClimbType,
+ difficulty: difficulty
+ )
+
+ dataManager.addProblem(newProblem)
+
+ let attempt = Attempt(
+ sessionId: session.id,
+ problemId: newProblem.id,
+ result: selectedResult,
+ highestHold: highestHold.isEmpty ? nil : highestHold,
+ notes: notes.isEmpty ? nil : notes,
+ duration: duration == 0 ? nil : duration,
+ restTime: restTime == 0 ? nil : restTime,
+ timestamp: Date()
+ )
+
+ dataManager.addAttempt(attempt)
+ } else {
+ guard let problem = selectedProblem else { return }
+
+ let attempt = Attempt(
+ sessionId: session.id,
+ problemId: problem.id,
+ result: selectedResult,
+ highestHold: highestHold.isEmpty ? nil : highestHold,
+ notes: notes.isEmpty ? nil : notes,
+ duration: duration > 0 ? duration : nil,
+ restTime: restTime > 0 ? restTime : nil
+ )
+
+ dataManager.addAttempt(attempt)
+ }
+
+ dismiss()
+ }
+}
+
+struct ProblemSelectionRow: View {
+ let problem: Problem
+ let isSelected: Bool
+ let action: () -> Void
+
+ var body: some View {
+ HStack {
+ VStack(alignment: .leading, spacing: 4) {
+ Text(problem.name ?? "Unnamed Problem")
+ .font(.headline)
+ .fontWeight(.medium)
+
+ Text("\(problem.difficulty.system.displayName): \(problem.difficulty.grade)")
+ .font(.subheadline)
+ .foregroundColor(.blue)
+
+ if let location = problem.location {
+ Text(location)
+ .font(.caption)
+ .foregroundColor(.secondary)
+ }
+ }
+
+ Spacer()
+
+ if isSelected {
+ Image(systemName: "checkmark.circle.fill")
+ .foregroundColor(.blue)
+ } else {
+ Image(systemName: "circle")
+ .foregroundColor(.gray)
+ }
+ }
+ .contentShape(Rectangle())
+ .onTapGesture(perform: action)
+ .padding(.vertical, 4)
+ }
+}
+
+struct EditAttemptView: View {
+ let attempt: Attempt
+ @EnvironmentObject var dataManager: ClimbingDataManager
+ @Environment(\.dismiss) private var dismiss
+
+ @State private var selectedProblem: Problem?
+ @State private var selectedResult: AttemptResult
+ @State private var highestHold: String
+ @State private var notes: String
+ @State private var duration: Int
+ @State private var restTime: Int
+
+ private var availableProblems: [Problem] {
+ dataManager.problems.filter { $0.isActive }
+ }
+
+ init(attempt: Attempt) {
+ self.attempt = attempt
+ self._selectedResult = State(initialValue: attempt.result)
+ self._highestHold = State(initialValue: attempt.highestHold ?? "")
+ self._notes = State(initialValue: attempt.notes ?? "")
+ self._duration = State(initialValue: attempt.duration ?? 0)
+ self._restTime = State(initialValue: attempt.restTime ?? 0)
+ }
+
+ var body: some View {
+ NavigationView {
+ Form {
+ Section("Problem") {
+ if availableProblems.isEmpty {
+ Text("No problems available")
+ .foregroundColor(.secondary)
+ } else {
+ ForEach(availableProblems, id: \.id) { problem in
+ HStack {
+ VStack(alignment: .leading, spacing: 4) {
+ Text(problem.name ?? "Unnamed Problem")
+ .font(.headline)
+
+ Text(
+ "\(problem.difficulty.system.displayName): \(problem.difficulty.grade)"
+ )
+ .font(.subheadline)
+ .foregroundColor(.blue)
+ }
+
+ Spacer()
+
+ if selectedProblem?.id == problem.id {
+ Image(systemName: "checkmark.circle.fill")
+ .foregroundColor(.blue)
+ }
+ }
+ .contentShape(Rectangle())
+ .onTapGesture {
+ selectedProblem = problem
+ }
+ }
+ }
+ }
+
+ Section("Result") {
+ ForEach(AttemptResult.allCases, id: \.self) { result in
+ HStack {
+ Text(result.displayName)
+ Spacer()
+ if selectedResult == result {
+ Image(systemName: "checkmark.circle.fill")
+ .foregroundColor(.blue)
+ } else {
+ Image(systemName: "circle")
+ .foregroundColor(.gray)
+ }
+ }
+ .contentShape(Rectangle())
+ .onTapGesture {
+ selectedResult = result
+ }
+ }
+ }
+
+ Section("Details") {
+ TextField("Highest Hold (Optional)", text: $highestHold)
+
+ VStack(alignment: .leading, spacing: 8) {
+ Text("Notes (Optional)")
+ .font(.headline)
+
+ TextEditor(text: $notes)
+ .frame(minHeight: 80)
+ .padding(8)
+ .background(
+ RoundedRectangle(cornerRadius: 8)
+ .fill(.quaternary)
+ )
+ }
+
+ HStack {
+ Text("Duration (seconds)")
+ Spacer()
+ TextField("0", value: $duration, format: .number)
+ .keyboardType(.numberPad)
+ .textFieldStyle(.roundedBorder)
+ .frame(width: 80)
+ }
+
+ HStack {
+ Text("Rest Time (seconds)")
+ Spacer()
+ TextField("0", value: $restTime, format: .number)
+ .keyboardType(.numberPad)
+ .textFieldStyle(.roundedBorder)
+ .frame(width: 80)
+ }
+ }
+ }
+ .navigationTitle("Edit Attempt")
+ .navigationBarTitleDisplayMode(.inline)
+ .toolbar {
+ ToolbarItem(placement: .navigationBarLeading) {
+ Button("Cancel") {
+ dismiss()
+ }
+ }
+
+ ToolbarItem(placement: .navigationBarTrailing) {
+ Button("Update") {
+ updateAttempt()
+ }
+ .disabled(selectedProblem == nil)
+ }
+ }
+ }
+ .onAppear {
+ selectedProblem = dataManager.problem(withId: attempt.problemId)
+ }
+ }
+
+ private func updateAttempt() {
+ guard let problem = selectedProblem else { return }
+
+ let updatedAttempt = attempt.updated(
+ result: selectedResult,
+ highestHold: highestHold.isEmpty ? nil : highestHold,
+ notes: notes.isEmpty ? nil : notes,
+ duration: duration > 0 ? duration : nil,
+ restTime: restTime > 0 ? restTime : nil
+ )
+
+ dataManager.updateAttempt(updatedAttempt)
+ dismiss()
+ }
+}
+
+#Preview {
+ AddAttemptView(
+ session: ClimbSession(gymId: UUID()),
+ gym: Gym(
+ name: "Sample Gym",
+ supportedClimbTypes: [.boulder],
+ difficultySystems: [.vScale]
+ )
+ )
+ .environmentObject(ClimbingDataManager.preview)
+}
diff --git a/ios/OpenClimb/Views/AddEdit/AddEditGymView.swift b/ios/OpenClimb/Views/AddEdit/AddEditGymView.swift
new file mode 100644
index 0000000..c0a5e21
--- /dev/null
+++ b/ios/OpenClimb/Views/AddEdit/AddEditGymView.swift
@@ -0,0 +1,216 @@
+//
+// AddEditGymView.swift
+// OpenClimb
+//
+// Created by OpenClimb on 2025-01-17.
+//
+
+import SwiftUI
+
+struct AddEditGymView: View {
+ let gymId: UUID?
+ @EnvironmentObject var dataManager: ClimbingDataManager
+ @Environment(\.dismiss) private var dismiss
+
+ @State private var name = ""
+ @State private var location = ""
+ @State private var notes = ""
+ @State private var selectedClimbTypes = Set()
+ @State private var selectedDifficultySystems = Set()
+ @State private var customDifficultyGrades: [String] = []
+ @State private var isEditing = false
+
+ private var existingGym: Gym? {
+ guard let gymId = gymId else { return nil }
+ return dataManager.gym(withId: gymId)
+ }
+
+ private var availableDifficultySystems: [DifficultySystem] {
+ if selectedClimbTypes.isEmpty {
+ return []
+ } else {
+ return selectedClimbTypes.flatMap { climbType in
+ DifficultySystem.systemsForClimbType(climbType)
+ }.removingDuplicates()
+ }
+ }
+
+ init(gymId: UUID? = nil) {
+ self.gymId = gymId
+ }
+
+ var body: some View {
+ NavigationView {
+ Form {
+ BasicInfoSection()
+ ClimbTypesSection()
+ DifficultySystemsSection()
+ NotesSection()
+ }
+ .navigationTitle(isEditing ? "Edit Gym" : "Add Gym")
+ .navigationBarTitleDisplayMode(.inline)
+ .toolbar {
+ ToolbarItem(placement: .navigationBarLeading) {
+ Button("Cancel") {
+ dismiss()
+ }
+ }
+
+ ToolbarItem(placement: .navigationBarTrailing) {
+ Button("Save") {
+ saveGym()
+ }
+ .disabled(!canSave)
+ }
+ }
+ }
+ .onAppear {
+ loadExistingGym()
+ }
+ .onChange(of: selectedClimbTypes) { _ in
+ updateAvailableDifficultySystems()
+ }
+ }
+
+ @ViewBuilder
+ private func BasicInfoSection() -> some View {
+ Section("Basic Information") {
+ TextField("Gym Name", text: $name)
+
+ TextField("Location (Optional)", text: $location)
+ }
+ }
+
+ @ViewBuilder
+ private func ClimbTypesSection() -> some View {
+ Section("Supported Climb Types") {
+ ForEach(ClimbType.allCases, id: \.self) { climbType in
+ HStack {
+ Text(climbType.displayName)
+ Spacer()
+ if selectedClimbTypes.contains(climbType) {
+ Image(systemName: "checkmark.circle.fill")
+ .foregroundColor(.blue)
+ } else {
+ Image(systemName: "circle")
+ .foregroundColor(.gray)
+ }
+ }
+ .contentShape(Rectangle())
+ .onTapGesture {
+ if selectedClimbTypes.contains(climbType) {
+ selectedClimbTypes.remove(climbType)
+ } else {
+ selectedClimbTypes.insert(climbType)
+ }
+ }
+ }
+ }
+ }
+
+ @ViewBuilder
+ private func DifficultySystemsSection() -> some View {
+ Section("Difficulty Systems") {
+ if selectedClimbTypes.isEmpty {
+ Text("Select climb types first to see available difficulty systems")
+ .foregroundColor(.secondary)
+ .font(.caption)
+ } else {
+ ForEach(availableDifficultySystems, id: \.self) { system in
+ HStack {
+ Text(system.displayName)
+ Spacer()
+ if selectedDifficultySystems.contains(system) {
+ Image(systemName: "checkmark.circle.fill")
+ .foregroundColor(.blue)
+ } else {
+ Image(systemName: "circle")
+ .foregroundColor(.gray)
+ }
+ }
+ .contentShape(Rectangle())
+ .onTapGesture {
+ if selectedDifficultySystems.contains(system) {
+ selectedDifficultySystems.remove(system)
+ } else {
+ selectedDifficultySystems.insert(system)
+ }
+ }
+ }
+ }
+ }
+ }
+
+ @ViewBuilder
+ private func NotesSection() -> some View {
+ Section("Notes (Optional)") {
+ TextEditor(text: $notes)
+ .frame(minHeight: 100)
+ }
+ }
+
+ private var canSave: Bool {
+ !name.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty && !selectedClimbTypes.isEmpty
+ && !selectedDifficultySystems.isEmpty
+ }
+
+ private func loadExistingGym() {
+ if let gym = existingGym {
+ isEditing = true
+ name = gym.name
+ location = gym.location ?? ""
+ notes = gym.notes ?? ""
+ selectedClimbTypes = Set(gym.supportedClimbTypes)
+ selectedDifficultySystems = Set(gym.difficultySystems)
+ customDifficultyGrades = gym.customDifficultyGrades
+ }
+ }
+
+ private func updateAvailableDifficultySystems() {
+ // Remove selected systems that are no longer available
+ let availableSet = Set(availableDifficultySystems)
+ selectedDifficultySystems = selectedDifficultySystems.intersection(availableSet)
+ }
+
+ private func saveGym() {
+ let trimmedName = name.trimmingCharacters(in: .whitespacesAndNewlines)
+ let trimmedLocation = location.trimmingCharacters(in: .whitespacesAndNewlines)
+ let trimmedNotes = notes.trimmingCharacters(in: .whitespacesAndNewlines)
+
+ if isEditing, let gym = existingGym {
+ let updatedGym = gym.updated(
+ name: trimmedName,
+ location: trimmedLocation.isEmpty ? nil : trimmedLocation,
+ supportedClimbTypes: Array(selectedClimbTypes),
+ difficultySystems: Array(selectedDifficultySystems),
+ customDifficultyGrades: customDifficultyGrades,
+ notes: trimmedNotes.isEmpty ? nil : trimmedNotes
+ )
+ dataManager.updateGym(updatedGym)
+ } else {
+ let newGym = Gym(
+ name: trimmedName,
+ location: trimmedLocation.isEmpty ? nil : trimmedLocation,
+ supportedClimbTypes: Array(selectedClimbTypes),
+ difficultySystems: Array(selectedDifficultySystems),
+ customDifficultyGrades: customDifficultyGrades,
+ notes: trimmedNotes.isEmpty ? nil : trimmedNotes
+ )
+ dataManager.addGym(newGym)
+ }
+
+ dismiss()
+ }
+}
+
+extension Array where Element: Hashable {
+ func removingDuplicates() -> [Element] {
+ var seen = Set()
+ return filter { seen.insert($0).inserted }
+ }
+}
+
+#Preview {
+ AddEditGymView()
+ .environmentObject(ClimbingDataManager.preview)
+}
diff --git a/ios/OpenClimb/Views/AddEdit/AddEditProblemView.swift b/ios/OpenClimb/Views/AddEdit/AddEditProblemView.swift
new file mode 100644
index 0000000..a418153
--- /dev/null
+++ b/ios/OpenClimb/Views/AddEdit/AddEditProblemView.swift
@@ -0,0 +1,529 @@
+//
+// AddEditProblemView.swift
+// OpenClimb
+//
+// Created by OpenClimb on 2025-01-17.
+//
+
+import PhotosUI
+import SwiftUI
+
+struct AddEditProblemView: View {
+ let problemId: UUID?
+ let gymId: UUID?
+ @EnvironmentObject var dataManager: ClimbingDataManager
+ @Environment(\.dismiss) private var dismiss
+
+ @State private var selectedGym: Gym?
+ @State private var name = ""
+ @State private var description = ""
+ @State private var selectedClimbType: ClimbType = .boulder
+ @State private var selectedDifficultySystem: DifficultySystem = .vScale
+ @State private var difficultyGrade = ""
+ @State private var setter = ""
+ @State private var location = ""
+ @State private var tags = ""
+ @State private var notes = ""
+ @State private var isActive = true
+ @State private var dateSet = Date()
+ @State private var imagePaths: [String] = []
+ @State private var selectedPhotos: [PhotosPickerItem] = []
+ @State private var imageData: [Data] = []
+ @State private var isEditing = false
+
+ private var existingProblem: Problem? {
+ guard let problemId = problemId else { return nil }
+ return dataManager.problem(withId: problemId)
+ }
+
+ private var availableClimbTypes: [ClimbType] {
+ selectedGym?.supportedClimbTypes ?? ClimbType.allCases
+ }
+
+ var availableDifficultySystems: [DifficultySystem] {
+ guard let gym = selectedGym else {
+ return DifficultySystem.systemsForClimbType(selectedClimbType)
+ }
+
+ let compatibleSystems = DifficultySystem.systemsForClimbType(selectedClimbType)
+ let gymSupportedSystems = gym.difficultySystems.filter { system in
+ compatibleSystems.contains(system)
+ }
+
+ return gymSupportedSystems.isEmpty ? compatibleSystems : gymSupportedSystems
+ }
+
+ private var availableGrades: [String] {
+ selectedDifficultySystem.availableGrades
+ }
+
+ init(problemId: UUID? = nil, gymId: UUID? = nil) {
+ self.problemId = problemId
+ self.gymId = gymId
+ }
+
+ var body: some View {
+ NavigationView {
+ Form {
+ GymSelectionSection()
+ BasicInfoSection()
+ ClimbTypeSection()
+ DifficultySection()
+ LocationAndSetterSection()
+ TagsSection()
+ PhotosSection()
+ AdditionalInfoSection()
+ }
+ .navigationTitle(isEditing ? "Edit Problem" : "Add Problem")
+ .navigationBarTitleDisplayMode(.inline)
+ .toolbar {
+ ToolbarItem(placement: .navigationBarLeading) {
+ Button("Cancel") {
+ dismiss()
+ }
+ }
+
+ ToolbarItem(placement: .navigationBarTrailing) {
+ Button("Save") {
+ saveProblem()
+ }
+ .disabled(!canSave)
+ }
+ }
+ }
+ .onAppear {
+ loadExistingProblem()
+ setupInitialGym()
+ }
+ .onChange(of: selectedGym) { _ in
+ updateAvailableOptions()
+ }
+ .onChange(of: selectedClimbType) { _ in
+ updateDifficultySystem()
+ }
+ .onChange(of: selectedDifficultySystem) { _ in
+ resetGradeIfNeeded()
+ }
+ .onChange(of: selectedPhotos) { _ in
+ Task {
+ await loadSelectedPhotos()
+ }
+ }
+ }
+
+ @ViewBuilder
+ private func GymSelectionSection() -> some View {
+ Section("Select Gym") {
+ if dataManager.gyms.isEmpty {
+ Text("No gyms available. Add a gym first.")
+ .foregroundColor(.secondary)
+ } else {
+ ForEach(dataManager.gyms, id: \.id) { gym in
+ HStack {
+ VStack(alignment: .leading, spacing: 4) {
+ Text(gym.name)
+ .font(.headline)
+
+ if let location = gym.location, !location.isEmpty {
+ Text(location)
+ .font(.caption)
+ .foregroundColor(.secondary)
+ }
+ }
+
+ Spacer()
+
+ if selectedGym?.id == gym.id {
+ Image(systemName: "checkmark.circle.fill")
+ .foregroundColor(.blue)
+ }
+ }
+ .contentShape(Rectangle())
+ .onTapGesture {
+ selectedGym = gym
+ }
+ }
+ }
+ }
+ }
+
+ @ViewBuilder
+ private func BasicInfoSection() -> some View {
+ Section("Problem Details") {
+ TextField("Problem Name (Optional)", text: $name)
+
+ VStack(alignment: .leading, spacing: 8) {
+ Text("Description (Optional)")
+ .font(.headline)
+
+ TextEditor(text: $description)
+ .frame(minHeight: 80)
+ .padding(8)
+ .background(
+ RoundedRectangle(cornerRadius: 8)
+ .fill(.quaternary)
+ )
+ }
+
+ TextField("Route Setter (Optional)", text: $setter)
+ }
+ }
+
+ @ViewBuilder
+ private func ClimbTypeSection() -> some View {
+ if let gym = selectedGym {
+ Section("Climb Type") {
+ ForEach(availableClimbTypes, id: \.self) { climbType in
+ HStack {
+ Text(climbType.displayName)
+ Spacer()
+ if selectedClimbType == climbType {
+ Image(systemName: "checkmark.circle.fill")
+ .foregroundColor(.blue)
+ } else {
+ Image(systemName: "circle")
+ .foregroundColor(.gray)
+ }
+ }
+ .contentShape(Rectangle())
+ .onTapGesture {
+ selectedClimbType = climbType
+ }
+ }
+ }
+ }
+ }
+
+ @ViewBuilder
+ private func DifficultySection() -> some View {
+ Section("Difficulty") {
+ // Difficulty System
+ VStack(alignment: .leading, spacing: 8) {
+ Text("Difficulty System")
+ .font(.headline)
+
+ ForEach(availableDifficultySystems, id: \.self) { system in
+ HStack {
+ Text(system.displayName)
+ Spacer()
+ if selectedDifficultySystem == system {
+ Image(systemName: "checkmark.circle.fill")
+ .foregroundColor(.blue)
+ } else {
+ Image(systemName: "circle")
+ .foregroundColor(.gray)
+ }
+ }
+ .contentShape(Rectangle())
+ .onTapGesture {
+ selectedDifficultySystem = system
+ }
+ }
+ }
+
+ // Grade Selection
+ VStack(alignment: .leading, spacing: 8) {
+ Text("Grade (Required)")
+ .font(.headline)
+
+ if selectedDifficultySystem == .custom || availableGrades.isEmpty {
+ TextField("Enter custom grade", text: $difficultyGrade)
+ .textFieldStyle(.roundedBorder)
+ } else {
+ Menu {
+ if !difficultyGrade.isEmpty {
+ Button("Clear Selection") {
+ difficultyGrade = ""
+ }
+
+ Divider()
+ }
+
+ ForEach(availableGrades, id: \.self) { grade in
+ Button(grade) {
+ difficultyGrade = grade
+ }
+ }
+ } label: {
+ HStack {
+ Text(difficultyGrade.isEmpty ? "Select Grade" : difficultyGrade)
+ .foregroundColor(difficultyGrade.isEmpty ? .secondary : .primary)
+ .fontWeight(difficultyGrade.isEmpty ? .regular : .semibold)
+
+ Spacer()
+
+ Image(systemName: "chevron.down")
+ .foregroundColor(.secondary)
+ .font(.caption)
+ }
+ .padding()
+ .background(
+ RoundedRectangle(cornerRadius: 8)
+ .fill(.gray.opacity(0.1))
+ .stroke(
+ difficultyGrade.isEmpty
+ ? .red.opacity(0.5) : .gray.opacity(0.3), lineWidth: 1)
+ )
+ }
+ .buttonStyle(.plain)
+ }
+
+ if difficultyGrade.isEmpty {
+ Text("Please select a grade to continue")
+ .font(.caption)
+ .foregroundColor(.red)
+ .italic()
+ } else {
+ Text("Selected: \(difficultyGrade)")
+ .font(.caption)
+ .foregroundColor(.blue)
+ }
+ }
+ }
+ }
+
+ @ViewBuilder
+ private func LocationAndSetterSection() -> some View {
+ Section("Location & Details") {
+ TextField(
+ "Location (Optional)", text: $location, prompt: Text("e.g., 'Cave area', 'Wall 3'"))
+
+ DatePicker(
+ "Date Set",
+ selection: $dateSet,
+ displayedComponents: [.date]
+ )
+ }
+ }
+
+ @ViewBuilder
+ private func TagsSection() -> some View {
+ Section("Tags (Optional)") {
+ TextField("Tags", text: $tags, prompt: Text("e.g., crimpy, dynamic (comma-separated)"))
+ }
+ }
+
+ @ViewBuilder
+ private func PhotosSection() -> some View {
+ Section("Photos") {
+ PhotosPicker(
+ selection: $selectedPhotos,
+ maxSelectionCount: 5,
+ matching: .images
+ ) {
+ HStack {
+ Image(systemName: "photo.on.rectangle.angled")
+ .foregroundColor(.blue)
+ Text("Add Photos (\(imageData.count)/5)")
+ Spacer()
+ }
+ }
+
+ if !imageData.isEmpty {
+ ScrollView(.horizontal, showsIndicators: false) {
+ HStack(spacing: 12) {
+ ForEach(imageData.indices, id: \.self) { index in
+ if let uiImage = UIImage(data: imageData[index]) {
+ Image(uiImage: uiImage)
+ .resizable()
+ .aspectRatio(contentMode: .fill)
+ .frame(width: 80, height: 80)
+ .clipped()
+ .cornerRadius(8)
+ .overlay(alignment: .topTrailing) {
+ Button(action: {
+ imageData.remove(at: index)
+ if index < imagePaths.count {
+ imagePaths.remove(at: index)
+ }
+ }) {
+ Image(systemName: "xmark.circle.fill")
+ .foregroundColor(.red)
+ .background(Circle().fill(.white))
+ }
+ .offset(x: 8, y: -8)
+ }
+ } else {
+ RoundedRectangle(cornerRadius: 8)
+ .fill(.gray.opacity(0.3))
+ .frame(width: 80, height: 80)
+ .overlay {
+ Image(systemName: "photo")
+ .foregroundColor(.gray)
+ }
+ }
+ }
+ }
+ .padding(.horizontal, 1)
+ }
+ }
+ }
+ }
+
+ @ViewBuilder
+ private func AdditionalInfoSection() -> some View {
+ Section("Additional Information") {
+ VStack(alignment: .leading, spacing: 8) {
+ Text("Notes (Optional)")
+ .font(.headline)
+
+ TextEditor(text: $notes)
+ .frame(minHeight: 80)
+ .padding(8)
+ .background(
+ RoundedRectangle(cornerRadius: 8)
+ .fill(.quaternary)
+ )
+ }
+
+ Toggle("Problem is currently active", isOn: $isActive)
+ }
+ }
+
+ private var canSave: Bool {
+ selectedGym != nil
+ && !difficultyGrade.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
+ }
+
+ private func setupInitialGym() {
+ if let gymId = gymId, selectedGym == nil {
+ selectedGym = dataManager.gym(withId: gymId)
+ }
+ }
+
+ private func loadExistingProblem() {
+ if let problem = existingProblem {
+ isEditing = true
+ selectedGym = dataManager.gym(withId: problem.gymId)
+ name = problem.name ?? ""
+ description = problem.description ?? ""
+ selectedClimbType = problem.climbType
+ selectedDifficultySystem = problem.difficulty.system
+ difficultyGrade = problem.difficulty.grade
+ setter = problem.setter ?? ""
+ location = problem.location ?? ""
+ tags = problem.tags.joined(separator: ", ")
+ notes = problem.notes ?? ""
+ isActive = problem.isActive
+ imagePaths = problem.imagePaths
+
+ // Load image data for preview
+ imageData = []
+ for imagePath in problem.imagePaths {
+ if let data = try? Data(contentsOf: URL(fileURLWithPath: imagePath)) {
+ imageData.append(data)
+ }
+ }
+
+ if let dateSet = problem.dateSet {
+ self.dateSet = dateSet
+ }
+ }
+ }
+
+ private func updateAvailableOptions() {
+ guard let gym = selectedGym else { return }
+
+ // Auto-select climb type if there's only one available
+ if gym.supportedClimbTypes.count == 1, selectedClimbType != gym.supportedClimbTypes.first! {
+ selectedClimbType = gym.supportedClimbTypes.first!
+ }
+
+ updateDifficultySystem()
+ }
+
+ private func updateDifficultySystem() {
+ let available = availableDifficultySystems
+
+ if !available.contains(selectedDifficultySystem) {
+ selectedDifficultySystem = available.first ?? .custom
+ }
+
+ if available.count == 1, selectedDifficultySystem != available.first! {
+ selectedDifficultySystem = available.first!
+ }
+ }
+
+ private func resetGradeIfNeeded() {
+ let availableGrades = selectedDifficultySystem.availableGrades
+ if !availableGrades.isEmpty && !availableGrades.contains(difficultyGrade) {
+ difficultyGrade = ""
+ }
+ }
+
+ private func loadSelectedPhotos() async {
+ for item in selectedPhotos {
+ if let data = try? await item.loadTransferable(type: Data.self) {
+ // Save to app's documents directory
+ let documentsPath = FileManager.default.urls(
+ for: .documentDirectory, in: .userDomainMask
+ ).first!
+ let imageName = "photo_\(UUID().uuidString).jpg"
+ let imagePath = documentsPath.appendingPathComponent(imageName)
+
+ do {
+ try data.write(to: imagePath)
+ imagePaths.append(imagePath.path)
+ imageData.append(data)
+ } catch {
+ print("Failed to save image: \(error)")
+ }
+ }
+ }
+ selectedPhotos.removeAll()
+ }
+
+ private func saveProblem() {
+ guard let gym = selectedGym else { return }
+
+ let trimmedName = name.trimmingCharacters(in: .whitespacesAndNewlines)
+ let trimmedDescription = description.trimmingCharacters(in: .whitespacesAndNewlines)
+ let trimmedSetter = setter.trimmingCharacters(in: .whitespacesAndNewlines)
+ let trimmedLocation = location.trimmingCharacters(in: .whitespacesAndNewlines)
+ let trimmedNotes = notes.trimmingCharacters(in: .whitespacesAndNewlines)
+ let trimmedTags = tags.split(separator: ",").map {
+ $0.trimmingCharacters(in: .whitespacesAndNewlines)
+ }.filter { !$0.isEmpty }
+
+ let difficulty = DifficultyGrade(system: selectedDifficultySystem, grade: difficultyGrade)
+
+ if isEditing, let problem = existingProblem {
+ let updatedProblem = problem.updated(
+ name: trimmedName.isEmpty ? nil : trimmedName,
+ description: trimmedDescription.isEmpty ? nil : trimmedDescription,
+ climbType: selectedClimbType,
+ difficulty: difficulty,
+ setter: trimmedSetter.isEmpty ? nil : trimmedSetter,
+ tags: trimmedTags,
+ location: trimmedLocation.isEmpty ? nil : trimmedLocation,
+ imagePaths: imagePaths,
+ isActive: isActive,
+ dateSet: dateSet,
+ notes: trimmedNotes.isEmpty ? nil : trimmedNotes
+ )
+ dataManager.updateProblem(updatedProblem)
+ } else {
+ let newProblem = Problem(
+ gymId: gym.id,
+ name: trimmedName.isEmpty ? nil : trimmedName,
+ description: trimmedDescription.isEmpty ? nil : trimmedDescription,
+ climbType: selectedClimbType,
+ difficulty: difficulty,
+ setter: trimmedSetter.isEmpty ? nil : trimmedSetter,
+ tags: trimmedTags,
+ location: trimmedLocation.isEmpty ? nil : trimmedLocation,
+ imagePaths: imagePaths,
+ dateSet: dateSet,
+ notes: trimmedNotes.isEmpty ? nil : trimmedNotes
+ )
+ dataManager.addProblem(newProblem)
+ }
+
+ dismiss()
+ }
+}
+
+#Preview {
+ AddEditProblemView()
+ .environmentObject(ClimbingDataManager.preview)
+}
diff --git a/ios/OpenClimb/Views/AddEdit/AddEditSessionView.swift b/ios/OpenClimb/Views/AddEdit/AddEditSessionView.swift
new file mode 100644
index 0000000..cd34e3a
--- /dev/null
+++ b/ios/OpenClimb/Views/AddEdit/AddEditSessionView.swift
@@ -0,0 +1,143 @@
+//
+// AddEditSessionView.swift
+// OpenClimb
+//
+// Created by OpenClimb on 2025-01-17.
+//
+
+import SwiftUI
+
+struct AddEditSessionView: View {
+ let sessionId: UUID?
+ @EnvironmentObject var dataManager: ClimbingDataManager
+ @Environment(\.dismiss) private var dismiss
+
+ @State private var selectedGym: Gym?
+ @State private var sessionDate = Date()
+ @State private var notes = ""
+ @State private var isEditing = false
+
+ private var existingSession: ClimbSession? {
+ guard let sessionId = sessionId else { return nil }
+ return dataManager.session(withId: sessionId)
+ }
+
+ init(sessionId: UUID? = nil) {
+ self.sessionId = sessionId
+ }
+
+ var body: some View {
+ NavigationView {
+ Form {
+ GymSelectionSection()
+ SessionDetailsSection()
+ }
+ .navigationTitle(isEditing ? "Edit Session" : "New Session")
+ .navigationBarTitleDisplayMode(.inline)
+ .toolbar {
+ ToolbarItem(placement: .navigationBarLeading) {
+ Button("Cancel") {
+ dismiss()
+ }
+ }
+
+ ToolbarItem(placement: .navigationBarTrailing) {
+ Button("Save") {
+ saveSession()
+ }
+ .disabled(selectedGym == nil)
+ }
+ }
+ }
+ .onAppear {
+ loadExistingSession()
+ }
+ }
+
+ @ViewBuilder
+ private func GymSelectionSection() -> some View {
+ Section("Select Gym") {
+ if dataManager.gyms.isEmpty {
+ Text("No gyms available. Add a gym first.")
+ .foregroundColor(.secondary)
+ } else {
+ ForEach(dataManager.gyms, id: \.id) { gym in
+ HStack {
+ VStack(alignment: .leading, spacing: 4) {
+ Text(gym.name)
+ .font(.headline)
+
+ if let location = gym.location, !location.isEmpty {
+ Text(location)
+ .font(.caption)
+ .foregroundColor(.secondary)
+ }
+ }
+
+ Spacer()
+
+ if selectedGym?.id == gym.id {
+ Image(systemName: "checkmark.circle.fill")
+ .foregroundColor(.blue)
+ }
+ }
+ .contentShape(Rectangle())
+ .onTapGesture {
+ selectedGym = gym
+ }
+ }
+ }
+ }
+ }
+
+ @ViewBuilder
+ private func SessionDetailsSection() -> some View {
+ Section("Session Details") {
+ DatePicker(
+ "Date",
+ selection: $sessionDate,
+ displayedComponents: [.date]
+ )
+
+ VStack(alignment: .leading, spacing: 8) {
+ Text("Notes (Optional)")
+ .font(.headline)
+
+ TextEditor(text: $notes)
+ .frame(minHeight: 100)
+ .padding(8)
+ .background(
+ RoundedRectangle(cornerRadius: 8)
+ .fill(.quaternary)
+ )
+ }
+ }
+ }
+
+ private func loadExistingSession() {
+ if let session = existingSession {
+ isEditing = true
+ selectedGym = dataManager.gym(withId: session.gymId)
+ sessionDate = session.date
+ notes = session.notes ?? ""
+ }
+ }
+
+ private func saveSession() {
+ guard let gym = selectedGym else { return }
+
+ if isEditing, let session = existingSession {
+ let updatedSession = session.updated(notes: notes.isEmpty ? nil : notes)
+ dataManager.updateSession(updatedSession)
+ } else {
+ dataManager.startSession(gymId: gym.id, notes: notes.isEmpty ? nil : notes)
+ }
+
+ dismiss()
+ }
+}
+
+#Preview {
+ AddEditSessionView()
+ .environmentObject(ClimbingDataManager.preview)
+}
diff --git a/ios/OpenClimb/Views/AnalyticsView.swift b/ios/OpenClimb/Views/AnalyticsView.swift
new file mode 100644
index 0000000..7a767b9
--- /dev/null
+++ b/ios/OpenClimb/Views/AnalyticsView.swift
@@ -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)
+}
diff --git a/ios/OpenClimb/Views/Detail/GymDetailView.swift b/ios/OpenClimb/Views/Detail/GymDetailView.swift
new file mode 100644
index 0000000..e1058a1
--- /dev/null
+++ b/ios/OpenClimb/Views/Detail/GymDetailView.swift
@@ -0,0 +1,430 @@
+//
+// GymDetailView.swift
+// OpenClimb
+//
+// Created by OpenClimb on 2025-01-17.
+//
+
+import SwiftUI
+
+struct GymDetailView: View {
+ let gymId: UUID
+ @EnvironmentObject var dataManager: ClimbingDataManager
+ @Environment(\.dismiss) private var dismiss
+ @State private var showingDeleteAlert = false
+
+ private var gym: Gym? {
+ dataManager.gym(withId: gymId)
+ }
+
+ private var problems: [Problem] {
+ dataManager.problems(forGym: gymId)
+ }
+
+ private var sessions: [ClimbSession] {
+ dataManager.sessions(forGym: gymId)
+ }
+
+ private var gymAttempts: [Attempt] {
+ let problemIds = Set(problems.map { $0.id })
+ return dataManager.attempts.filter { problemIds.contains($0.problemId) }
+ }
+
+ private var gymStats: GymStats {
+ calculateGymStats()
+ }
+
+ var body: some View {
+ ScrollView {
+ LazyVStack(spacing: 20) {
+ if let gym = gym {
+ GymHeaderCard(gym: gym)
+
+ GymStatsCard(stats: gymStats)
+
+ if !problems.isEmpty {
+ RecentProblemsSection(problems: problems.prefix(5))
+ }
+
+ if !sessions.isEmpty {
+ RecentSessionsSection(sessions: sessions.prefix(3))
+ }
+
+ if problems.isEmpty && sessions.isEmpty {
+ EmptyGymStateView()
+ }
+ } else {
+ Text("Gym not found")
+ .foregroundColor(.secondary)
+ }
+ }
+ .padding()
+ }
+ .navigationTitle(gym?.name ?? "Gym Details")
+ .navigationBarTitleDisplayMode(.inline)
+ .toolbar {
+ ToolbarItemGroup(placement: .navigationBarTrailing) {
+ if gym != nil {
+ Menu {
+ Button("Edit Gym") {
+ // Navigate to edit view
+ }
+
+ Button(role: .destructive) {
+ showingDeleteAlert = true
+ } label: {
+ Label("Delete Gym", systemImage: "trash")
+ }
+ } label: {
+ Image(systemName: "ellipsis.circle")
+ }
+ }
+ }
+ }
+ .alert("Delete Gym", isPresented: $showingDeleteAlert) {
+ Button("Cancel", role: .cancel) {}
+ Button("Delete", role: .destructive) {
+ if let gym = gym {
+ dataManager.deleteGym(gym)
+ dismiss()
+ }
+ }
+ } message: {
+ Text(
+ "Are you sure you want to delete this gym? This will also delete all problems and sessions associated with this gym."
+ )
+ }
+ }
+
+ private func calculateGymStats() -> GymStats {
+ let uniqueProblemsClimbed = Set(gymAttempts.map { $0.problemId }).count
+ let totalSessions = sessions.count
+ let activeSessions = sessions.count { $0.status == .active }
+
+ return GymStats(
+ totalProblems: problems.count,
+ totalSessions: totalSessions,
+ totalAttempts: gymAttempts.count,
+ uniqueProblemsClimbed: uniqueProblemsClimbed,
+ activeSessions: activeSessions
+ )
+ }
+}
+
+struct GymHeaderCard: View {
+ let gym: Gym
+
+ var body: some View {
+ VStack(alignment: .leading, spacing: 16) {
+ VStack(alignment: .leading, spacing: 8) {
+ Text(gym.name)
+ .font(.title)
+ .fontWeight(.bold)
+
+ if let location = gym.location, !location.isEmpty {
+ Text(location)
+ .font(.subheadline)
+ .foregroundColor(.secondary)
+ }
+
+ if let notes = gym.notes, !notes.isEmpty {
+ Text(notes)
+ .font(.body)
+ .padding(.top, 4)
+ }
+ }
+
+ // Supported Climb Types
+ if !gym.supportedClimbTypes.isEmpty {
+ VStack(alignment: .leading, spacing: 8) {
+ Text("Climb Types")
+ .font(.headline)
+ .fontWeight(.semibold)
+
+ ScrollView(.horizontal, showsIndicators: false) {
+ HStack(spacing: 8) {
+ ForEach(gym.supportedClimbTypes, id: \.self) { climbType in
+ Text(climbType.displayName)
+ .font(.caption)
+ .padding(.horizontal, 12)
+ .padding(.vertical, 6)
+ .background(
+ RoundedRectangle(cornerRadius: 12)
+ .fill(.blue.opacity(0.1))
+ )
+ .foregroundColor(.blue)
+ }
+ }
+ .padding(.horizontal, 1)
+ }
+ }
+ }
+
+ // Difficulty Systems
+ if !gym.difficultySystems.isEmpty {
+ VStack(alignment: .leading, spacing: 8) {
+ Text("Difficulty Systems")
+ .font(.headline)
+ .fontWeight(.semibold)
+
+ Text(gym.difficultySystems.map { $0.displayName }.joined(separator: ", "))
+ .font(.subheadline)
+ .foregroundColor(.secondary)
+ }
+ }
+ }
+ .padding()
+ .background(
+ RoundedRectangle(cornerRadius: 16)
+ .fill(.regularMaterial)
+ )
+ }
+}
+
+struct GymStatsCard: View {
+ let stats: GymStats
+
+ var body: some View {
+ VStack(alignment: .leading, spacing: 16) {
+ Text("Statistics")
+ .font(.title2)
+ .fontWeight(.bold)
+
+ LazyVGrid(columns: Array(repeating: GridItem(.flexible()), count: 2), spacing: 16) {
+ StatItem(label: "Problems", value: "\(stats.totalProblems)")
+ StatItem(label: "Sessions", value: "\(stats.totalSessions)")
+ StatItem(label: "Total Attempts", value: "\(stats.totalAttempts)")
+ StatItem(label: "Problems Climbed", value: "\(stats.uniqueProblemsClimbed)")
+ }
+
+ if stats.activeSessions > 0 {
+ HStack {
+ StatItem(label: "Active Sessions", value: "\(stats.activeSessions)")
+ Spacer()
+ }
+ }
+ }
+ .padding()
+ .background(
+ RoundedRectangle(cornerRadius: 16)
+ .fill(.regularMaterial)
+ )
+ }
+}
+
+struct RecentProblemsSection: View {
+ let problems: any Sequence
+ @EnvironmentObject var dataManager: ClimbingDataManager
+
+ var body: some View {
+ VStack(alignment: .leading, spacing: 16) {
+ Text(
+ "Problems (\(dataManager.problems(forGym: Array(problems).first?.gymId ?? UUID()).count))"
+ )
+ .font(.title2)
+ .fontWeight(.bold)
+
+ LazyVStack(spacing: 12) {
+ ForEach(Array(problems), id: \.id) { problem in
+ NavigationLink(destination: ProblemDetailView(problemId: problem.id)) {
+ ProblemRowCard(problem: problem)
+ }
+ .buttonStyle(.plain)
+ }
+ }
+
+ if dataManager.problems(forGym: Array(problems).first?.gymId ?? UUID()).count > 5 {
+ Text(
+ "... and \(dataManager.problems(forGym: Array(problems).first?.gymId ?? UUID()).count - 5) more problems"
+ )
+ .font(.caption)
+ .foregroundColor(.secondary)
+ }
+ }
+ .padding()
+ .background(
+ RoundedRectangle(cornerRadius: 16)
+ .fill(.regularMaterial)
+ )
+ }
+}
+
+struct RecentSessionsSection: View {
+ let sessions: any Sequence
+ @EnvironmentObject var dataManager: ClimbingDataManager
+
+ var body: some View {
+ VStack(alignment: .leading, spacing: 16) {
+ Text(
+ "Recent Sessions (\(dataManager.sessions(forGym: Array(sessions).first?.gymId ?? UUID()).count))"
+ )
+ .font(.title2)
+ .fontWeight(.bold)
+
+ LazyVStack(spacing: 12) {
+ ForEach(Array(sessions), id: \.id) { session in
+ NavigationLink(destination: SessionDetailView(sessionId: session.id)) {
+ SessionRowCard(session: session)
+ }
+ .buttonStyle(.plain)
+ }
+ }
+
+ if dataManager.sessions(forGym: Array(sessions).first?.gymId ?? UUID()).count > 3 {
+ Text(
+ "... and \(dataManager.sessions(forGym: Array(sessions).first?.gymId ?? UUID()).count - 3) more sessions"
+ )
+ .font(.caption)
+ .foregroundColor(.secondary)
+ }
+ }
+ .padding()
+ .background(
+ RoundedRectangle(cornerRadius: 16)
+ .fill(.regularMaterial)
+ )
+ }
+}
+
+struct ProblemRowCard: View {
+ let problem: Problem
+ @EnvironmentObject var dataManager: ClimbingDataManager
+
+ private var problemAttempts: [Attempt] {
+ dataManager.attempts(forProblem: problem.id)
+ }
+
+ private var isCompleted: Bool {
+ problemAttempts.contains { $0.result.isSuccessful }
+ }
+
+ var body: some View {
+ HStack {
+ VStack(alignment: .leading, spacing: 4) {
+ Text(problem.name ?? "Unnamed Problem")
+ .font(.headline)
+ .fontWeight(.semibold)
+ .foregroundColor(.primary)
+
+ Text(
+ "\(problem.difficulty.grade) • \(problem.climbType.displayName) • \(problemAttempts.count) attempts"
+ )
+ .font(.subheadline)
+ .foregroundColor(.secondary)
+ }
+
+ Spacer()
+
+ if isCompleted {
+ Image(systemName: "checkmark.circle.fill")
+ .foregroundColor(.green)
+ }
+ }
+ .padding()
+ .background(
+ RoundedRectangle(cornerRadius: 12)
+ .fill(.ultraThinMaterial)
+ .stroke(.quaternary, lineWidth: 1)
+ )
+ }
+}
+
+struct SessionRowCard: View {
+ let session: ClimbSession
+ @EnvironmentObject var dataManager: ClimbingDataManager
+
+ private var sessionAttempts: [Attempt] {
+ dataManager.attempts(forSession: session.id)
+ }
+
+ var body: some View {
+ HStack {
+ VStack(alignment: .leading, spacing: 4) {
+ HStack {
+ Text(session.status == .active ? "Active Session" : "Session")
+ .font(.headline)
+ .fontWeight(.semibold)
+ .foregroundColor(.primary)
+
+ if session.status == .active {
+ Text("ACTIVE")
+ .font(.caption)
+ .fontWeight(.medium)
+ .padding(.horizontal, 6)
+ .padding(.vertical, 2)
+ .background(
+ RoundedRectangle(cornerRadius: 4)
+ .fill(.green.opacity(0.2))
+ )
+ .foregroundColor(.green)
+ }
+ }
+
+ Text("\(formatDate(session.date)) • \(sessionAttempts.count) attempts")
+ .font(.subheadline)
+ .foregroundColor(.secondary)
+ }
+
+ Spacer()
+
+ if let duration = session.duration {
+ Text("\(duration)min")
+ .font(.caption)
+ .foregroundColor(.secondary)
+ }
+ }
+ .padding()
+ .background(
+ RoundedRectangle(cornerRadius: 12)
+ .fill(.ultraThinMaterial)
+ .stroke(.quaternary, lineWidth: 1)
+ )
+ }
+
+ private func formatDate(_ date: Date) -> String {
+ let formatter = DateFormatter()
+ formatter.dateStyle = .medium
+ return formatter.string(from: date)
+ }
+}
+
+struct EmptyGymStateView: View {
+ var body: some View {
+ VStack(spacing: 20) {
+ Image(systemName: "figure.climbing")
+ .font(.system(size: 60))
+ .foregroundColor(.secondary)
+
+ VStack(spacing: 8) {
+ Text("No activity yet")
+ .font(.title2)
+ .fontWeight(.bold)
+
+ Text("Start a session or add problems to see them here")
+ .font(.body)
+ .foregroundColor(.secondary)
+ .multilineTextAlignment(.center)
+ }
+ }
+ .padding(40)
+ .background(
+ RoundedRectangle(cornerRadius: 16)
+ .fill(.regularMaterial)
+ )
+ }
+}
+
+struct GymStats {
+ let totalProblems: Int
+ let totalSessions: Int
+ let totalAttempts: Int
+ let uniqueProblemsClimbed: Int
+ let activeSessions: Int
+}
+
+#Preview {
+ NavigationView {
+ GymDetailView(gymId: UUID())
+ .environmentObject(ClimbingDataManager.preview)
+ }
+}
diff --git a/ios/OpenClimb/Views/Detail/ProblemDetailView.swift b/ios/OpenClimb/Views/Detail/ProblemDetailView.swift
new file mode 100644
index 0000000..32ac919
--- /dev/null
+++ b/ios/OpenClimb/Views/Detail/ProblemDetailView.swift
@@ -0,0 +1,476 @@
+//
+// ProblemDetailView.swift
+// OpenClimb
+//
+// Created by OpenClimb on 2025-01-17.
+//
+
+import SwiftUI
+
+struct ProblemDetailView: View {
+ let problemId: UUID
+ @EnvironmentObject var dataManager: ClimbingDataManager
+ @Environment(\.dismiss) private var dismiss
+ @State private var showingDeleteAlert = false
+ @State private var showingImageViewer = false
+ @State private var selectedImageIndex = 0
+ @State private var showingEditProblem = false
+
+ private var problem: Problem? {
+ dataManager.problem(withId: problemId)
+ }
+
+ private var gym: Gym? {
+ guard let problem = problem else { return nil }
+ return dataManager.gym(withId: problem.gymId)
+ }
+
+ private var attempts: [Attempt] {
+ dataManager.attempts(forProblem: problemId)
+ }
+
+ private var successfulAttempts: [Attempt] {
+ attempts.filter { $0.result.isSuccessful }
+ }
+
+ private var attemptsWithSessions: [(Attempt, ClimbSession)] {
+ attempts.compactMap { attempt in
+ guard let session = dataManager.session(withId: attempt.sessionId) else { return nil }
+ return (attempt, session)
+ }.sorted { $0.1.date > $1.1.date }
+ }
+
+ var body: some View {
+ ScrollView {
+ LazyVStack(spacing: 20) {
+ if let problem = problem, let gym = gym {
+ ProblemHeaderCard(problem: problem, gym: gym)
+
+ ProgressSummaryCard(
+ totalAttempts: attempts.count,
+ successfulAttempts: successfulAttempts.count,
+ firstSuccess: firstSuccessInfo
+ )
+
+ if !problem.imagePaths.isEmpty {
+ PhotosSection(imagePaths: problem.imagePaths)
+ }
+
+ AttemptHistorySection(attemptsWithSessions: attemptsWithSessions)
+ } else {
+ Text("Problem not found")
+ .foregroundColor(.secondary)
+ }
+ }
+ .padding()
+ }
+ .navigationTitle("Problem Details")
+ .navigationBarTitleDisplayMode(.inline)
+ .toolbar {
+ ToolbarItemGroup(placement: .navigationBarTrailing) {
+ if problem != nil {
+ Menu {
+ Button("Edit Problem") {
+ showingEditProblem = true
+ }
+
+ Button(role: .destructive) {
+ showingDeleteAlert = true
+ } label: {
+ Label("Delete Problem", systemImage: "trash")
+ }
+ } label: {
+ Image(systemName: "ellipsis.circle")
+ }
+ }
+ }
+ }
+ .alert("Delete Problem", isPresented: $showingDeleteAlert) {
+ Button("Cancel", role: .cancel) {}
+ Button("Delete", role: .destructive) {
+ if let problem = problem {
+ dataManager.deleteProblem(problem)
+ dismiss()
+ }
+ }
+ } message: {
+ Text(
+ "Are you sure you want to delete this problem? This will also delete all attempts associated with this problem."
+ )
+ }
+ .sheet(isPresented: $showingEditProblem) {
+ if let problem = problem {
+ AddEditProblemView(problemId: problem.id)
+ }
+ }
+ .sheet(isPresented: $showingImageViewer) {
+ if let problem = problem, !problem.imagePaths.isEmpty {
+ ImageViewerView(
+ imagePaths: problem.imagePaths,
+ initialIndex: selectedImageIndex
+ )
+ }
+ }
+ }
+
+ private var firstSuccessInfo: (date: Date, result: AttemptResult)? {
+ guard
+ let firstSuccess = successfulAttempts.min(by: { attempt1, attempt2 in
+ let session1 = dataManager.session(withId: attempt1.sessionId)
+ let session2 = dataManager.session(withId: attempt2.sessionId)
+ return session1?.date ?? Date() < session2?.date ?? Date()
+ })
+ else { return nil }
+
+ let session = dataManager.session(withId: firstSuccess.sessionId)
+ return (date: session?.date ?? Date(), result: firstSuccess.result)
+ }
+}
+
+struct ProblemHeaderCard: View {
+ let problem: Problem
+ let gym: Gym
+
+ var body: some View {
+ VStack(alignment: .leading, spacing: 16) {
+ HStack {
+ VStack(alignment: .leading, spacing: 8) {
+ Text(problem.name ?? "Unnamed Problem")
+ .font(.title)
+ .fontWeight(.bold)
+
+ Text(gym.name)
+ .font(.title2)
+ .foregroundColor(.secondary)
+
+ if let location = problem.location {
+ Text(location)
+ .font(.subheadline)
+ .foregroundColor(.secondary)
+ }
+ }
+
+ Spacer()
+
+ VStack(alignment: .trailing, spacing: 8) {
+ Text(problem.difficulty.grade)
+ .font(.title)
+ .fontWeight(.bold)
+ .foregroundColor(.blue)
+
+ Text(problem.climbType.displayName)
+ .font(.subheadline)
+ .foregroundColor(.secondary)
+
+ Text(problem.difficulty.system.displayName)
+ .font(.caption)
+ .foregroundColor(.secondary)
+ }
+ }
+
+ if let description = problem.description, !description.isEmpty {
+ Text(description)
+ .font(.body)
+ }
+
+ if let setter = problem.setter, !setter.isEmpty {
+ Text("Set by: \(setter)")
+ .font(.subheadline)
+ .foregroundColor(.secondary)
+ }
+
+ if !problem.tags.isEmpty {
+ ScrollView(.horizontal, showsIndicators: false) {
+ HStack(spacing: 8) {
+ ForEach(problem.tags, id: \.self) { tag in
+ Text(tag)
+ .font(.caption)
+ .padding(.horizontal, 8)
+ .padding(.vertical, 4)
+ .background(
+ RoundedRectangle(cornerRadius: 8)
+ .fill(.blue.opacity(0.1))
+ )
+ .foregroundColor(.blue)
+ }
+ }
+ .padding(.horizontal, 1)
+ }
+ }
+
+ if let notes = problem.notes, !notes.isEmpty {
+ Text(notes)
+ .font(.caption)
+ .foregroundColor(.secondary)
+ .padding(.top, 4)
+ }
+
+ if !problem.isActive {
+ HStack {
+ Image(systemName: "exclamationmark.triangle.fill")
+ .foregroundColor(.orange)
+ Text("Inactive Problem")
+ .font(.subheadline)
+ .fontWeight(.medium)
+ .foregroundColor(.orange)
+ }
+ .padding(.horizontal, 12)
+ .padding(.vertical, 6)
+ .background(
+ RoundedRectangle(cornerRadius: 8)
+ .fill(.orange.opacity(0.1))
+ )
+ }
+ }
+ .padding()
+ .background(
+ RoundedRectangle(cornerRadius: 16)
+ .fill(.regularMaterial)
+ )
+ }
+}
+
+struct ProgressSummaryCard: View {
+ let totalAttempts: Int
+ let successfulAttempts: Int
+ let firstSuccess: (date: Date, result: AttemptResult)?
+
+ var body: some View {
+ VStack(alignment: .leading, spacing: 16) {
+ Text("Progress Summary")
+ .font(.title2)
+ .fontWeight(.bold)
+
+ if totalAttempts == 0 {
+ Text("No attempts recorded yet")
+ .foregroundColor(.secondary)
+ .frame(maxWidth: .infinity, alignment: .center)
+ .padding()
+ } else {
+ HStack {
+ StatItem(label: "Total Attempts", value: "\(totalAttempts)")
+ StatItem(label: "Successful", value: "\(successfulAttempts)")
+ }
+
+ if let firstSuccess = firstSuccess {
+ VStack(alignment: .leading, spacing: 4) {
+ Text("First Success")
+ .font(.subheadline)
+ .fontWeight(.medium)
+
+ Text(
+ "\(formatDate(firstSuccess.date)) (\(firstSuccess.result.displayName))"
+ )
+ .font(.subheadline)
+ .foregroundColor(.blue)
+ }
+ .padding(.top, 8)
+ }
+ }
+ }
+ .padding()
+ .background(
+ RoundedRectangle(cornerRadius: 16)
+ .fill(.regularMaterial)
+ )
+ }
+
+ private func formatDate(_ date: Date) -> String {
+ let formatter = DateFormatter()
+ formatter.dateStyle = .medium
+ return formatter.string(from: date)
+ }
+}
+
+struct PhotosSection: View {
+ let imagePaths: [String]
+ @State private var showingImageViewer = false
+ @State private var selectedImageIndex = 0
+
+ var body: some View {
+ VStack(alignment: .leading, spacing: 12) {
+ Text("Photos")
+ .font(.title2)
+ .fontWeight(.bold)
+
+ ScrollView(.horizontal, showsIndicators: false) {
+ HStack(spacing: 12) {
+ ForEach(imagePaths.indices, id: \.self) { index in
+ AsyncImage(url: URL(fileURLWithPath: imagePaths[index])) { image in
+ image
+ .resizable()
+ .aspectRatio(contentMode: .fill)
+ } placeholder: {
+ RoundedRectangle(cornerRadius: 12)
+ .fill(.gray.opacity(0.3))
+ }
+ .frame(width: 120, height: 120)
+ .clipped()
+ .cornerRadius(12)
+ .onTapGesture {
+ selectedImageIndex = index
+ showingImageViewer = true
+ }
+ }
+ }
+ .padding(.horizontal, 1)
+ }
+ }
+ .padding()
+ .background(
+ RoundedRectangle(cornerRadius: 16)
+ .fill(.regularMaterial)
+ )
+ .sheet(isPresented: $showingImageViewer) {
+ ImageViewerView(
+ imagePaths: imagePaths,
+ initialIndex: selectedImageIndex
+ )
+ }
+ }
+}
+
+struct AttemptHistorySection: View {
+ let attemptsWithSessions: [(Attempt, ClimbSession)]
+ @EnvironmentObject var dataManager: ClimbingDataManager
+
+ var body: some View {
+ VStack(alignment: .leading, spacing: 16) {
+ Text("Attempt History (\(attemptsWithSessions.count))")
+ .font(.title2)
+ .fontWeight(.bold)
+
+ if attemptsWithSessions.isEmpty {
+ VStack(spacing: 12) {
+ Image(systemName: "hand.raised.slash")
+ .font(.title)
+ .foregroundColor(.secondary)
+
+ Text("No attempts yet")
+ .font(.headline)
+ .foregroundColor(.secondary)
+
+ Text("Start a session and track your attempts on this problem!")
+ .font(.subheadline)
+ .foregroundColor(.secondary)
+ .multilineTextAlignment(.center)
+ }
+ .frame(maxWidth: .infinity)
+ .padding()
+ .background(
+ RoundedRectangle(cornerRadius: 16)
+ .fill(.regularMaterial)
+ )
+ } else {
+ LazyVStack(spacing: 12) {
+ ForEach(attemptsWithSessions.indices, id: \.self) { index in
+ let (attempt, session) = attemptsWithSessions[index]
+ AttemptHistoryCard(attempt: attempt, session: session)
+ }
+ }
+ }
+ }
+ }
+}
+
+struct AttemptHistoryCard: View {
+ let attempt: Attempt
+ let session: ClimbSession
+ @EnvironmentObject var dataManager: ClimbingDataManager
+
+ private var gym: Gym? {
+ dataManager.gym(withId: session.gymId)
+ }
+
+ var body: some View {
+ VStack(alignment: .leading, spacing: 12) {
+ HStack {
+ VStack(alignment: .leading, spacing: 4) {
+ Text(formatDate(session.date))
+ .font(.headline)
+ .fontWeight(.semibold)
+
+ if let gym = gym {
+ Text(gym.name)
+ .font(.subheadline)
+ .foregroundColor(.secondary)
+ }
+ }
+
+ Spacer()
+
+ AttemptResultBadge(result: attempt.result)
+ }
+
+ if let notes = attempt.notes, !notes.isEmpty {
+ Text(notes)
+ .font(.subheadline)
+ .foregroundColor(.secondary)
+ }
+
+ if let highestHold = attempt.highestHold, !highestHold.isEmpty {
+ Text("Highest hold: \(highestHold)")
+ .font(.caption)
+ .foregroundColor(.secondary)
+ }
+ }
+ .padding()
+ .background(
+ RoundedRectangle(cornerRadius: 12)
+ .fill(.ultraThinMaterial)
+ )
+ }
+
+ private func formatDate(_ date: Date) -> String {
+ let formatter = DateFormatter()
+ formatter.dateStyle = .medium
+ return formatter.string(from: date)
+ }
+}
+
+struct ImageViewerView: View {
+ let imagePaths: [String]
+ let initialIndex: Int
+ @Environment(\.dismiss) private var dismiss
+ @State private var currentIndex: Int
+
+ init(imagePaths: [String], initialIndex: Int) {
+ self.imagePaths = imagePaths
+ self.initialIndex = initialIndex
+ self._currentIndex = State(initialValue: initialIndex)
+ }
+
+ var body: some View {
+ NavigationView {
+ TabView(selection: $currentIndex) {
+ ForEach(imagePaths.indices, id: \.self) { index in
+ AsyncImage(url: URL(fileURLWithPath: imagePaths[index])) { image in
+ image
+ .resizable()
+ .aspectRatio(contentMode: .fit)
+ } placeholder: {
+ ProgressView()
+ }
+ .tag(index)
+ }
+ }
+ .tabViewStyle(.page(indexDisplayMode: .always))
+ .navigationTitle("Photo \(currentIndex + 1) of \(imagePaths.count)")
+ .navigationBarTitleDisplayMode(.inline)
+ .toolbar {
+ ToolbarItem(placement: .navigationBarTrailing) {
+ Button("Done") {
+ dismiss()
+ }
+ }
+ }
+ }
+ }
+}
+
+#Preview {
+ NavigationView {
+ ProblemDetailView(problemId: UUID())
+ .environmentObject(ClimbingDataManager.preview)
+ }
+}
diff --git a/ios/OpenClimb/Views/Detail/SessionDetailView.swift b/ios/OpenClimb/Views/Detail/SessionDetailView.swift
new file mode 100644
index 0000000..e71d3b9
--- /dev/null
+++ b/ios/OpenClimb/Views/Detail/SessionDetailView.swift
@@ -0,0 +1,443 @@
+//
+// SessionDetailView.swift
+// OpenClimb
+//
+// Created by OpenClimb on 2025-01-17.
+//
+
+import SwiftUI
+
+struct SessionDetailView: View {
+ let sessionId: UUID
+ @EnvironmentObject var dataManager: ClimbingDataManager
+ @Environment(\.dismiss) private var dismiss
+ @State private var showingDeleteAlert = false
+ @State private var showingAddAttempt = false
+ @State private var editingAttempt: Attempt?
+
+ private var session: ClimbSession? {
+ dataManager.session(withId: sessionId)
+ }
+
+ private var gym: Gym? {
+ guard let session = session else { return nil }
+ return dataManager.gym(withId: session.gymId)
+ }
+
+ private var attempts: [Attempt] {
+ dataManager.attempts(forSession: sessionId)
+ }
+
+ private var attemptsWithProblems: [(Attempt, Problem)] {
+ attempts.compactMap { attempt in
+ guard let problem = dataManager.problem(withId: attempt.problemId) else { return nil }
+ return (attempt, problem)
+ }.sorted { $0.0.timestamp < $1.0.timestamp }
+ }
+
+ private var sessionStats: SessionStats {
+ calculateSessionStats()
+ }
+
+ var body: some View {
+ ScrollView {
+ LazyVStack(spacing: 20) {
+ if let session = session, let gym = gym {
+ SessionHeaderCard(session: session, gym: gym, stats: sessionStats)
+
+ SessionStatsCard(stats: sessionStats)
+
+ AttemptsSection(attemptsWithProblems: attemptsWithProblems)
+ } else {
+ Text("Session not found")
+ .foregroundColor(.secondary)
+ }
+ }
+ .padding()
+ }
+ .navigationTitle("Session Details")
+ .navigationBarTitleDisplayMode(.inline)
+ .toolbar {
+ ToolbarItemGroup(placement: .navigationBarTrailing) {
+ if let session = session {
+ if session.status == .active {
+ Button("End Session") {
+ dataManager.endSession(session.id)
+ dismiss()
+ }
+ .foregroundColor(.orange)
+ } else {
+ Menu {
+ Button(role: .destructive) {
+ showingDeleteAlert = true
+ } label: {
+ Label("Delete Session", systemImage: "trash")
+ }
+ } label: {
+ Image(systemName: "ellipsis.circle")
+ }
+ }
+ }
+ }
+ }
+ .overlay(alignment: .bottomTrailing) {
+ if session?.status == .active {
+ Button(action: { showingAddAttempt = true }) {
+ Image(systemName: "plus")
+ .font(.title2)
+ .foregroundColor(.white)
+ .frame(width: 56, height: 56)
+ .background(Circle().fill(.blue))
+ .shadow(radius: 4)
+ }
+ .padding()
+ }
+ }
+ .alert("Delete Session", isPresented: $showingDeleteAlert) {
+ Button("Cancel", role: .cancel) {}
+ Button("Delete", role: .destructive) {
+ if let session = session {
+ dataManager.deleteSession(session)
+ dismiss()
+ }
+ }
+ } message: {
+ Text(
+ "Are you sure you want to delete this session? This will also delete all attempts associated with this session."
+ )
+ }
+ .sheet(isPresented: $showingAddAttempt) {
+ if let session = session, let gym = gym {
+ AddAttemptView(session: session, gym: gym)
+ }
+ }
+ .sheet(item: $editingAttempt) { attempt in
+ EditAttemptView(attempt: attempt)
+ }
+ }
+
+ private func calculateSessionStats() -> SessionStats {
+ let successfulAttempts = attempts.filter { $0.result.isSuccessful }
+ let uniqueProblems = Set(attempts.map { $0.problemId })
+ let completedProblems = Set(successfulAttempts.map { $0.problemId })
+
+ let attemptedProblems = uniqueProblems.compactMap { dataManager.problem(withId: $0) }
+ let boulderProblems = attemptedProblems.filter { $0.climbType == .boulder }
+ let ropeProblems = attemptedProblems.filter { $0.climbType == .rope }
+
+ let boulderRange = gradeRange(for: boulderProblems)
+ let ropeRange = gradeRange(for: ropeProblems)
+
+ return SessionStats(
+ totalAttempts: attempts.count,
+ successfulAttempts: successfulAttempts.count,
+ uniqueProblemsAttempted: uniqueProblems.count,
+ uniqueProblemsCompleted: completedProblems.count,
+ boulderRange: boulderRange,
+ ropeRange: ropeRange
+ )
+ }
+
+ private func gradeRange(for problems: [Problem]) -> String? {
+ guard !problems.isEmpty else { return nil }
+ let grades = problems.map { $0.difficulty }.sorted()
+ if grades.count == 1 {
+ return grades.first?.grade
+ } else {
+ return "\(grades.first?.grade ?? "") - \(grades.last?.grade ?? "")"
+ }
+ }
+}
+
+struct SessionHeaderCard: View {
+ let session: ClimbSession
+ let gym: Gym
+ let stats: SessionStats
+
+ var body: some View {
+ VStack(alignment: .leading, spacing: 16) {
+ VStack(alignment: .leading, spacing: 8) {
+ Text(gym.name)
+ .font(.title)
+ .fontWeight(.bold)
+
+ Text(formatDate(session.date))
+ .font(.title2)
+ .foregroundColor(.blue)
+
+ if let duration = session.duration {
+ Text("Duration: \(duration) minutes")
+ .font(.subheadline)
+ .foregroundColor(.secondary)
+ }
+
+ if let notes = session.notes, !notes.isEmpty {
+ Text(notes)
+ .font(.body)
+ .padding(.top, 4)
+ }
+ }
+
+ // Status indicator
+ HStack {
+ Image(systemName: session.status == .active ? "play.fill" : "checkmark.circle.fill")
+ .foregroundColor(session.status == .active ? .green : .blue)
+
+ Text(session.status == .active ? "In Progress" : "Completed")
+ .font(.subheadline)
+ .fontWeight(.medium)
+ .foregroundColor(session.status == .active ? .green : .blue)
+
+ Spacer()
+ }
+ .padding(.horizontal, 12)
+ .padding(.vertical, 6)
+ .background(
+ RoundedRectangle(cornerRadius: 8)
+ .fill((session.status == .active ? Color.green : Color.blue).opacity(0.1))
+ )
+ }
+ .padding()
+ .background(
+ RoundedRectangle(cornerRadius: 16)
+ .fill(.regularMaterial)
+ )
+ }
+
+ private func formatDate(_ date: Date) -> String {
+ let formatter = DateFormatter()
+ formatter.dateStyle = .full
+ return formatter.string(from: date)
+ }
+}
+
+struct SessionStatsCard: View {
+ let stats: SessionStats
+
+ var body: some View {
+ VStack(alignment: .leading, spacing: 16) {
+ Text("Session Stats")
+ .font(.title2)
+ .fontWeight(.bold)
+
+ if stats.totalAttempts == 0 {
+ Text("No attempts recorded yet")
+ .foregroundColor(.secondary)
+ .frame(maxWidth: .infinity, alignment: .center)
+ .padding()
+ } else {
+ LazyVGrid(columns: Array(repeating: GridItem(.flexible()), count: 2), spacing: 16) {
+ StatItem(label: "Total Attempts", value: "\(stats.totalAttempts)")
+ StatItem(label: "Problems", value: "\(stats.uniqueProblemsAttempted)")
+ StatItem(label: "Successful", value: "\(stats.successfulAttempts)")
+ StatItem(label: "Completed", value: "\(stats.uniqueProblemsCompleted)")
+ }
+
+ // Grade ranges
+ VStack(alignment: .leading, spacing: 8) {
+ if let boulderRange = stats.boulderRange, let ropeRange = stats.ropeRange {
+ HStack {
+ StatItem(label: "Boulder Range", value: boulderRange)
+ StatItem(label: "Rope Range", value: ropeRange)
+ }
+ } else if let singleRange = stats.boulderRange ?? stats.ropeRange {
+ StatItem(label: "Grade Range", value: singleRange)
+ .frame(maxWidth: .infinity, alignment: .center)
+ }
+ }
+ }
+ }
+ .padding()
+ .background(
+ RoundedRectangle(cornerRadius: 16)
+ .fill(.regularMaterial)
+ )
+ }
+}
+
+struct StatItem: View {
+ let label: String
+ let value: String
+
+ var body: some View {
+ VStack(spacing: 4) {
+ Text(value)
+ .font(.title2)
+ .fontWeight(.bold)
+ .foregroundColor(.blue)
+
+ Text(label)
+ .font(.caption)
+ .foregroundColor(.secondary)
+ }
+ .frame(maxWidth: .infinity)
+ }
+}
+
+struct AttemptsSection: View {
+ let attemptsWithProblems: [(Attempt, Problem)]
+ @EnvironmentObject var dataManager: ClimbingDataManager
+ @State private var editingAttempt: Attempt?
+
+ var body: some View {
+ VStack(alignment: .leading, spacing: 16) {
+ Text("Attempts (\(attemptsWithProblems.count))")
+ .font(.title2)
+ .fontWeight(.bold)
+
+ if attemptsWithProblems.isEmpty {
+ VStack(spacing: 12) {
+ Image(systemName: "hand.raised.slash")
+ .font(.title)
+ .foregroundColor(.secondary)
+
+ Text("No attempts yet")
+ .font(.headline)
+ .foregroundColor(.secondary)
+
+ Text("Start attempting problems to see your progress!")
+ .font(.subheadline)
+ .foregroundColor(.secondary)
+ .multilineTextAlignment(.center)
+ }
+ .frame(maxWidth: .infinity)
+ .padding()
+ .background(
+ RoundedRectangle(cornerRadius: 16)
+ .fill(.regularMaterial)
+ )
+ } else {
+ LazyVStack(spacing: 12) {
+ ForEach(attemptsWithProblems.indices, id: \.self) { index in
+ let (attempt, problem) = attemptsWithProblems[index]
+ AttemptCard(attempt: attempt, problem: problem)
+ .onTapGesture {
+ editingAttempt = attempt
+ }
+ }
+ }
+ }
+ }
+ .sheet(item: $editingAttempt) { attempt in
+ EditAttemptView(attempt: attempt)
+ }
+ }
+}
+
+struct AttemptCard: View {
+ let attempt: Attempt
+ let problem: Problem
+ @EnvironmentObject var dataManager: ClimbingDataManager
+ @State private var showingDeleteAlert = false
+
+ var body: some View {
+ VStack(alignment: .leading, spacing: 12) {
+ HStack {
+ VStack(alignment: .leading, spacing: 4) {
+ Text(problem.name ?? "Unknown Problem")
+ .font(.headline)
+ .fontWeight(.semibold)
+
+ Text("\(problem.difficulty.system.displayName): \(problem.difficulty.grade)")
+ .font(.subheadline)
+ .foregroundColor(.blue)
+
+ if let location = problem.location {
+ Text(location)
+ .font(.caption)
+ .foregroundColor(.secondary)
+ }
+ }
+
+ Spacer()
+
+ VStack(alignment: .trailing, spacing: 8) {
+ AttemptResultBadge(result: attempt.result)
+
+ HStack(spacing: 12) {
+ Button(action: { showingDeleteAlert = true }) {
+ Image(systemName: "trash")
+ .font(.caption)
+ .foregroundColor(.red)
+ }
+ .buttonStyle(.plain)
+ }
+ }
+ }
+
+ if let notes = attempt.notes, !notes.isEmpty {
+ Text(notes)
+ .font(.subheadline)
+ .foregroundColor(.secondary)
+ }
+
+ if let highestHold = attempt.highestHold, !highestHold.isEmpty {
+ Text("Highest hold: \(highestHold)")
+ .font(.caption)
+ .foregroundColor(.secondary)
+ }
+ }
+ .padding()
+ .background(
+ RoundedRectangle(cornerRadius: 12)
+ .fill(.ultraThinMaterial)
+ .stroke(.quaternary, lineWidth: 1)
+ )
+ .alert("Delete Attempt", isPresented: $showingDeleteAlert) {
+ Button("Cancel", role: .cancel) {}
+ Button("Delete", role: .destructive) {
+ dataManager.deleteAttempt(attempt)
+ }
+ } message: {
+ Text("Are you sure you want to delete this attempt?")
+ }
+ }
+}
+
+struct AttemptResultBadge: View {
+ let result: AttemptResult
+
+ private var badgeColor: Color {
+ switch result {
+ case .success, .flash:
+ return .green
+ case .fall:
+ return .orange
+ case .noProgress:
+ return .red
+ }
+ }
+
+ var body: some View {
+ Text(result.displayName)
+ .font(.caption)
+ .fontWeight(.medium)
+ .padding(.horizontal, 8)
+ .padding(.vertical, 4)
+ .background(
+ RoundedRectangle(cornerRadius: 6)
+ .fill(badgeColor.opacity(0.1))
+ )
+ .foregroundColor(badgeColor)
+ .overlay(
+ RoundedRectangle(cornerRadius: 6)
+ .stroke(badgeColor.opacity(0.3), lineWidth: 1)
+ )
+ }
+}
+
+struct SessionStats {
+ let totalAttempts: Int
+ let successfulAttempts: Int
+ let uniqueProblemsAttempted: Int
+ let uniqueProblemsCompleted: Int
+ let boulderRange: String?
+ let ropeRange: String?
+}
+
+#Preview {
+ NavigationView {
+ SessionDetailView(sessionId: UUID())
+ .environmentObject(ClimbingDataManager.preview)
+ }
+}
diff --git a/ios/OpenClimb/Views/GymsView.swift b/ios/OpenClimb/Views/GymsView.swift
new file mode 100644
index 0000000..f209f38
--- /dev/null
+++ b/ios/OpenClimb/Views/GymsView.swift
@@ -0,0 +1,171 @@
+//
+// GymsView.swift
+// OpenClimb
+//
+// Created by OpenClimb on 2025-01-17.
+//
+
+import SwiftUI
+
+struct GymsView: View {
+ @EnvironmentObject var dataManager: ClimbingDataManager
+ @State private var showingAddGym = false
+
+ var body: some View {
+ NavigationView {
+ VStack {
+ if dataManager.gyms.isEmpty {
+ EmptyGymsView()
+ } else {
+ GymsList()
+ }
+ }
+ .navigationTitle("Gyms")
+ .toolbar {
+ ToolbarItem(placement: .navigationBarTrailing) {
+ Button("Add") {
+ showingAddGym = true
+ }
+ }
+ }
+ .sheet(isPresented: $showingAddGym) {
+ AddEditGymView()
+ }
+ }
+ }
+}
+
+struct GymsList: View {
+ @EnvironmentObject var dataManager: ClimbingDataManager
+
+ var body: some View {
+ List(dataManager.gyms, id: \.id) { gym in
+ NavigationLink(destination: GymDetailView(gymId: gym.id)) {
+ GymRow(gym: gym)
+ }
+ }
+ .listStyle(.plain)
+ }
+}
+
+struct GymRow: View {
+ let gym: Gym
+ @EnvironmentObject var dataManager: ClimbingDataManager
+
+ private var problemCount: Int {
+ dataManager.problems(forGym: gym.id).count
+ }
+
+ private var sessionCount: Int {
+ dataManager.sessions(forGym: gym.id).count
+ }
+
+ var body: some View {
+ VStack(alignment: .leading, spacing: 12) {
+ // Header
+ VStack(alignment: .leading, spacing: 4) {
+ Text(gym.name)
+ .font(.headline)
+ .fontWeight(.bold)
+
+ if let location = gym.location, !location.isEmpty {
+ Text(location)
+ .font(.subheadline)
+ .foregroundColor(.secondary)
+ }
+ }
+
+ // Climb Types
+ if !gym.supportedClimbTypes.isEmpty {
+ ScrollView(.horizontal, showsIndicators: false) {
+ HStack(spacing: 8) {
+ ForEach(gym.supportedClimbTypes, id: \.self) { climbType in
+ Text(climbType.displayName)
+ .font(.caption)
+ .padding(.horizontal, 8)
+ .padding(.vertical, 4)
+ .background(
+ RoundedRectangle(cornerRadius: 8)
+ .fill(.blue.opacity(0.1))
+ )
+ .foregroundColor(.blue)
+ }
+ }
+ }
+ }
+
+ // Difficulty Systems
+ if !gym.difficultySystems.isEmpty {
+ Text(
+ "Systems: \(gym.difficultySystems.map { $0.displayName }.joined(separator: ", "))"
+ )
+ .font(.caption)
+ .foregroundColor(.secondary)
+ }
+
+ // Stats
+ HStack {
+ Label("\(problemCount)", systemImage: "star.fill")
+ .font(.caption)
+ .foregroundColor(.orange)
+
+ Label("\(sessionCount)", systemImage: "play.fill")
+ .font(.caption)
+ .foregroundColor(.green)
+
+ Spacer()
+ }
+
+ // Notes preview
+ if let notes = gym.notes, !notes.isEmpty {
+ Text(notes)
+ .font(.caption)
+ .foregroundColor(.secondary)
+ .lineLimit(2)
+ }
+ }
+ .padding(.vertical, 4)
+ }
+}
+
+struct EmptyGymsView: View {
+ @State private var showingAddGym = false
+
+ var body: some View {
+ VStack(spacing: 20) {
+ Spacer()
+
+ Image(systemName: "location.fill")
+ .font(.system(size: 60))
+ .foregroundColor(.secondary)
+
+ VStack(spacing: 8) {
+ Text("No Gyms Added")
+ .font(.title2)
+ .fontWeight(.bold)
+
+ Text("Add your favorite climbing gyms to start tracking your progress!")
+ .font(.body)
+ .foregroundColor(.secondary)
+ .multilineTextAlignment(.center)
+ .padding(.horizontal)
+ }
+
+ Button("Add Gym") {
+ showingAddGym = true
+ }
+ .buttonStyle(.borderedProminent)
+ .controlSize(.large)
+
+ Spacer()
+ }
+ .sheet(isPresented: $showingAddGym) {
+ AddEditGymView()
+ }
+ }
+}
+
+#Preview {
+ GymsView()
+ .environmentObject(ClimbingDataManager.preview)
+}
diff --git a/ios/OpenClimb/Views/ProblemsView.swift b/ios/OpenClimb/Views/ProblemsView.swift
new file mode 100644
index 0000000..a5a73d5
--- /dev/null
+++ b/ios/OpenClimb/Views/ProblemsView.swift
@@ -0,0 +1,362 @@
+//
+// ProblemsView.swift
+// OpenClimb
+//
+// Created by OpenClimb on 2025-01-17.
+//
+
+import SwiftUI
+
+struct ProblemsView: View {
+ @EnvironmentObject var dataManager: ClimbingDataManager
+ @State private var showingAddProblem = false
+ @State private var selectedClimbType: ClimbType?
+ @State private var selectedGym: Gym?
+ @State private var searchText = ""
+
+ private var filteredProblems: [Problem] {
+ var filtered = dataManager.problems
+
+ // Apply search filter
+ if !searchText.isEmpty {
+ filtered = filtered.filter { problem in
+ (problem.name?.localizedCaseInsensitiveContains(searchText) ?? false)
+ || (problem.description?.localizedCaseInsensitiveContains(searchText) ?? false)
+ || (problem.location?.localizedCaseInsensitiveContains(searchText) ?? false)
+ || (problem.setter?.localizedCaseInsensitiveContains(searchText) ?? false)
+ || problem.tags.contains { $0.localizedCaseInsensitiveContains(searchText) }
+ }
+ }
+
+ // Apply climb type filter
+ if let climbType = selectedClimbType {
+ filtered = filtered.filter { $0.climbType == climbType }
+ }
+
+ // Apply gym filter
+ if let gym = selectedGym {
+ filtered = filtered.filter { $0.gymId == gym.id }
+ }
+
+ return filtered.sorted { $0.updatedAt > $1.updatedAt }
+ }
+
+ var body: some View {
+ NavigationView {
+ VStack(spacing: 0) {
+ if !dataManager.problems.isEmpty {
+ FilterSection()
+ .padding()
+ .background(.regularMaterial)
+ }
+
+ if filteredProblems.isEmpty {
+ EmptyProblemsView(
+ isEmpty: dataManager.problems.isEmpty,
+ isFiltered: !dataManager.problems.isEmpty
+ )
+ } else {
+ ProblemsList(problems: filteredProblems)
+ }
+ }
+ .navigationTitle("Problems")
+ .searchable(text: $searchText, prompt: "Search problems...")
+ .toolbar {
+ ToolbarItem(placement: .navigationBarTrailing) {
+ if !dataManager.gyms.isEmpty {
+ Button("Add") {
+ showingAddProblem = true
+ }
+ }
+ }
+ }
+ .sheet(isPresented: $showingAddProblem) {
+ AddEditProblemView()
+ }
+ }
+ }
+}
+
+struct FilterSection: View {
+ @EnvironmentObject var dataManager: ClimbingDataManager
+ @State private var selectedClimbType: ClimbType?
+ @State private var selectedGym: Gym?
+
+ var body: some View {
+ VStack(spacing: 12) {
+ // Climb Type Filter
+ VStack(alignment: .leading, spacing: 8) {
+ Text("Climb Type")
+ .font(.subheadline)
+ .fontWeight(.medium)
+
+ ScrollView(.horizontal, showsIndicators: false) {
+ HStack(spacing: 8) {
+ FilterChip(
+ title: "All Types",
+ isSelected: selectedClimbType == nil
+ ) {
+ selectedClimbType = nil
+ }
+
+ ForEach(ClimbType.allCases, id: \.self) { climbType in
+ FilterChip(
+ title: climbType.displayName,
+ isSelected: selectedClimbType == climbType
+ ) {
+ selectedClimbType = climbType
+ }
+ }
+ }
+ .padding(.horizontal, 1)
+ }
+ }
+
+ // Gym Filter
+ VStack(alignment: .leading, spacing: 8) {
+ Text("Gym")
+ .font(.subheadline)
+ .fontWeight(.medium)
+
+ ScrollView(.horizontal, showsIndicators: false) {
+ HStack(spacing: 8) {
+ FilterChip(
+ title: "All Gyms",
+ isSelected: selectedGym == nil
+ ) {
+ selectedGym = nil
+ }
+
+ ForEach(dataManager.gyms, id: \.id) { gym in
+ FilterChip(
+ title: gym.name,
+ isSelected: selectedGym?.id == gym.id
+ ) {
+ selectedGym = gym
+ }
+ }
+ }
+ .padding(.horizontal, 1)
+ }
+ }
+
+ // Results count
+ if selectedClimbType != nil || selectedGym != nil {
+ HStack {
+ Text(
+ "Showing \(filteredProblems.count) of \(dataManager.problems.count) problems"
+ )
+ .font(.caption)
+ .foregroundColor(.secondary)
+ Spacer()
+ }
+ }
+ }
+ }
+
+ private var filteredProblems: [Problem] {
+ var filtered = dataManager.problems
+
+ if let climbType = selectedClimbType {
+ filtered = filtered.filter { $0.climbType == climbType }
+ }
+
+ if let gym = selectedGym {
+ filtered = filtered.filter { $0.gymId == gym.id }
+ }
+
+ return filtered
+ }
+}
+
+struct FilterChip: View {
+ let title: String
+ let isSelected: Bool
+ let action: () -> Void
+
+ var body: some View {
+ Button(action: action) {
+ Text(title)
+ .font(.caption)
+ .fontWeight(.medium)
+ .padding(.horizontal, 12)
+ .padding(.vertical, 6)
+ .background(
+ RoundedRectangle(cornerRadius: 16)
+ .fill(isSelected ? .blue : .clear)
+ .stroke(.blue, lineWidth: 1)
+ )
+ .foregroundColor(isSelected ? .white : .blue)
+ }
+ .buttonStyle(.plain)
+ }
+}
+
+struct ProblemsList: View {
+ let problems: [Problem]
+ @EnvironmentObject var dataManager: ClimbingDataManager
+
+ var body: some View {
+ List(problems) { problem in
+ NavigationLink(destination: ProblemDetailView(problemId: problem.id)) {
+ ProblemRow(problem: problem)
+ }
+ }
+ .listStyle(.plain)
+ }
+}
+
+struct ProblemRow: View {
+ let problem: Problem
+ @EnvironmentObject var dataManager: ClimbingDataManager
+
+ private var gym: Gym? {
+ dataManager.gym(withId: problem.gymId)
+ }
+
+ var body: some View {
+ VStack(alignment: .leading, spacing: 8) {
+ HStack {
+ VStack(alignment: .leading, spacing: 4) {
+ Text(problem.name ?? "Unnamed Problem")
+ .font(.headline)
+ .fontWeight(.semibold)
+
+ Text(gym?.name ?? "Unknown Gym")
+ .font(.subheadline)
+ .foregroundColor(.secondary)
+ }
+
+ Spacer()
+
+ VStack(alignment: .trailing, spacing: 4) {
+ Text(problem.difficulty.grade)
+ .font(.title2)
+ .fontWeight(.bold)
+ .foregroundColor(.blue)
+
+ Text(problem.climbType.displayName)
+ .font(.caption)
+ .foregroundColor(.secondary)
+ }
+ }
+
+ if let location = problem.location {
+ Text("Location: \(location)")
+ .font(.caption)
+ .foregroundColor(.secondary)
+ }
+
+ if !problem.tags.isEmpty {
+ ScrollView(.horizontal, showsIndicators: false) {
+ HStack(spacing: 6) {
+ ForEach(problem.tags.prefix(3), id: \.self) { tag in
+ Text(tag)
+ .font(.caption2)
+ .padding(.horizontal, 6)
+ .padding(.vertical, 2)
+ .background(
+ RoundedRectangle(cornerRadius: 4)
+ .fill(.blue.opacity(0.1))
+ )
+ .foregroundColor(.blue)
+ }
+ }
+ }
+ }
+
+ if !problem.imagePaths.isEmpty {
+ ScrollView(.horizontal, showsIndicators: false) {
+ HStack(spacing: 8) {
+ ForEach(problem.imagePaths.prefix(3), id: \.self) { imagePath in
+ AsyncImage(url: URL(fileURLWithPath: imagePath)) { image in
+ image
+ .resizable()
+ .aspectRatio(contentMode: .fill)
+ } placeholder: {
+ RoundedRectangle(cornerRadius: 8)
+ .fill(.gray.opacity(0.3))
+ }
+ .frame(width: 60, height: 60)
+ .clipped()
+ .cornerRadius(8)
+ }
+ }
+ }
+ }
+
+ if !problem.isActive {
+ Text("Inactive")
+ .font(.caption)
+ .foregroundColor(.red)
+ .fontWeight(.medium)
+ }
+ }
+ .padding(.vertical, 4)
+ }
+}
+
+struct EmptyProblemsView: View {
+ let isEmpty: Bool
+ let isFiltered: Bool
+ @EnvironmentObject var dataManager: ClimbingDataManager
+ @State private var showingAddProblem = false
+
+ var body: some View {
+ VStack(spacing: 20) {
+ Spacer()
+
+ Image(systemName: "star.fill")
+ .font(.system(size: 60))
+ .foregroundColor(.secondary)
+
+ VStack(spacing: 8) {
+ Text(title)
+ .font(.title2)
+ .fontWeight(.bold)
+
+ Text(subtitle)
+ .font(.body)
+ .foregroundColor(.secondary)
+ .multilineTextAlignment(.center)
+ .padding(.horizontal)
+ }
+
+ if isEmpty && !dataManager.gyms.isEmpty {
+ Button("Add Problem") {
+ showingAddProblem = true
+ }
+ .buttonStyle(.borderedProminent)
+ .controlSize(.large)
+ }
+
+ Spacer()
+ }
+ .sheet(isPresented: $showingAddProblem) {
+ AddEditProblemView()
+ }
+ }
+
+ private var title: String {
+ if isEmpty {
+ return dataManager.gyms.isEmpty ? "No Gyms Available" : "No Problems Yet"
+ } else {
+ return "No Problems Match Filters"
+ }
+ }
+
+ private var subtitle: String {
+ if isEmpty {
+ return dataManager.gyms.isEmpty
+ ? "Add a gym first to start tracking problems and routes!"
+ : "Start tracking your favorite problems and routes!"
+ } else {
+ return "Try adjusting your filters to see more problems."
+ }
+ }
+}
+
+#Preview {
+ ProblemsView()
+ .environmentObject(ClimbingDataManager.preview)
+}
diff --git a/ios/OpenClimb/Views/SessionsView.swift b/ios/OpenClimb/Views/SessionsView.swift
new file mode 100644
index 0000000..e2e5947
--- /dev/null
+++ b/ios/OpenClimb/Views/SessionsView.swift
@@ -0,0 +1,243 @@
+//
+// SessionsView.swift
+// OpenClimb
+//
+// Created by OpenClimb on 2025-01-17.
+//
+
+import Combine
+import SwiftUI
+
+struct SessionsView: View {
+ @EnvironmentObject var dataManager: ClimbingDataManager
+ @State private var showingAddSession = false
+
+ var body: some View {
+ NavigationView {
+ VStack(spacing: 0) {
+ // Active session banner
+ if let activeSession = dataManager.activeSession,
+ let gym = dataManager.gym(withId: activeSession.gymId)
+ {
+ VStack(spacing: 8) {
+ ActiveSessionBanner(session: activeSession, gym: gym)
+ .padding(.horizontal)
+ }
+ .padding(.top, 8)
+ }
+
+ // Sessions list
+ if dataManager.sessions.isEmpty && dataManager.activeSession == nil {
+ EmptySessionsView()
+ } else {
+ SessionsList()
+ }
+ }
+ .navigationTitle("Sessions")
+ .toolbar {
+ ToolbarItem(placement: .navigationBarTrailing) {
+ if dataManager.gyms.isEmpty {
+ EmptyView()
+ } else if dataManager.activeSession == nil {
+ Button("Start Session") {
+ if dataManager.gyms.count == 1 {
+ dataManager.startSession(gymId: dataManager.gyms.first!.id)
+ } else {
+ showingAddSession = true
+ }
+ }
+ }
+ }
+ }
+ .sheet(isPresented: $showingAddSession) {
+ AddEditSessionView()
+ }
+ }
+ }
+}
+
+struct ActiveSessionBanner: View {
+ let session: ClimbSession
+ let gym: Gym
+ @EnvironmentObject var dataManager: ClimbingDataManager
+ @State private var currentTime = Date()
+
+ private let timer = Timer.publish(every: 1, on: .main, in: .common).autoconnect()
+
+ var body: some View {
+ NavigationLink(destination: SessionDetailView(sessionId: session.id)) {
+ HStack {
+ VStack(alignment: .leading, spacing: 4) {
+ HStack {
+ Image(systemName: "play.fill")
+ .foregroundColor(.green)
+ .font(.caption)
+ Text("Active Session")
+ .font(.headline)
+ .fontWeight(.bold)
+ }
+
+ Text(gym.name)
+ .font(.subheadline)
+ .foregroundColor(.secondary)
+
+ if let startTime = session.startTime {
+ Text(formatDuration(from: startTime, to: currentTime))
+ .font(.caption)
+ .foregroundColor(.secondary)
+ }
+ }
+
+ Spacer()
+
+ Button("End") {
+ dataManager.endSession(session.id)
+ }
+ .buttonStyle(.borderedProminent)
+ .controlSize(.small)
+ }
+ .padding()
+ .background(
+ RoundedRectangle(cornerRadius: 12)
+ .fill(.green.opacity(0.1))
+ .stroke(.green.opacity(0.3), lineWidth: 1)
+ )
+ }
+ .buttonStyle(.plain)
+ .onReceive(timer) { _ in
+ currentTime = Date()
+ }
+ }
+
+ private func formatDuration(from start: Date, to end: Date) -> String {
+ let interval = end.timeIntervalSince(start)
+ let hours = Int(interval) / 3600
+ let minutes = Int(interval) % 3600 / 60
+ let seconds = Int(interval) % 60
+
+ if hours > 0 {
+ return String(format: "%dh %dm %ds", hours, minutes, seconds)
+ } else if minutes > 0 {
+ return String(format: "%dm %ds", minutes, seconds)
+ } else {
+ return String(format: "%ds", seconds)
+ }
+ }
+}
+
+struct SessionsList: View {
+ @EnvironmentObject var dataManager: ClimbingDataManager
+
+ private var completedSessions: [ClimbSession] {
+ dataManager.sessions
+ .filter { $0.status == .completed }
+ .sorted { $0.date > $1.date }
+ }
+
+ var body: some View {
+ List(completedSessions) { session in
+ NavigationLink(destination: SessionDetailView(sessionId: session.id)) {
+ SessionRow(session: session)
+ }
+ }
+ .listStyle(.plain)
+ }
+}
+
+struct SessionRow: View {
+ let session: ClimbSession
+ @EnvironmentObject var dataManager: ClimbingDataManager
+
+ private var gym: Gym? {
+ dataManager.gym(withId: session.gymId)
+ }
+
+ var body: some View {
+ VStack(alignment: .leading, spacing: 8) {
+ HStack {
+ Text(gym?.name ?? "Unknown Gym")
+ .font(.headline)
+ .fontWeight(.semibold)
+
+ Spacer()
+
+ Text(formatDate(session.date))
+ .font(.caption)
+ .foregroundColor(.secondary)
+ }
+
+ if let duration = session.duration {
+ Text("Duration: \(duration) minutes")
+ .font(.subheadline)
+ .foregroundColor(.secondary)
+ }
+
+ if let notes = session.notes, !notes.isEmpty {
+ Text(notes)
+ .font(.caption)
+ .foregroundColor(.secondary)
+ .lineLimit(2)
+ }
+ }
+ .padding(.vertical, 4)
+ }
+
+ private func formatDate(_ date: Date) -> String {
+ let formatter = DateFormatter()
+ formatter.dateStyle = .medium
+ return formatter.string(from: date)
+ }
+}
+
+struct EmptySessionsView: View {
+ @EnvironmentObject var dataManager: ClimbingDataManager
+ @State private var showingAddSession = false
+
+ var body: some View {
+ VStack(spacing: 20) {
+ Spacer()
+
+ Image(systemName: "figure.climbing")
+ .font(.system(size: 60))
+ .foregroundColor(.secondary)
+
+ VStack(spacing: 8) {
+ Text(dataManager.gyms.isEmpty ? "No Gyms Available" : "No Sessions Yet")
+ .font(.title2)
+ .fontWeight(.bold)
+
+ Text(
+ dataManager.gyms.isEmpty
+ ? "Add a gym first to start tracking your climbing sessions!"
+ : "Start your first climbing session!"
+ )
+ .font(.body)
+ .foregroundColor(.secondary)
+ .multilineTextAlignment(.center)
+ .padding(.horizontal)
+ }
+
+ if !dataManager.gyms.isEmpty {
+ Button("Start Session") {
+ if dataManager.gyms.count == 1 {
+ dataManager.startSession(gymId: dataManager.gyms.first!.id)
+ } else {
+ showingAddSession = true
+ }
+ }
+ .buttonStyle(.borderedProminent)
+ .controlSize(.large)
+ }
+
+ Spacer()
+ }
+ .sheet(isPresented: $showingAddSession) {
+ AddEditSessionView()
+ }
+ }
+}
+
+#Preview {
+ SessionsView()
+ .environmentObject(ClimbingDataManager.preview)
+}
diff --git a/ios/OpenClimb/Views/SettingsView.swift b/ios/OpenClimb/Views/SettingsView.swift
new file mode 100644
index 0000000..91866fe
--- /dev/null
+++ b/ios/OpenClimb/Views/SettingsView.swift
@@ -0,0 +1,441 @@
+//
+// SettingsView.swift
+// OpenClimb
+//
+// Created by OpenClimb on 2025-01-17.
+//
+
+import SwiftUI
+import UniformTypeIdentifiers
+
+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?
+
+ var body: some View {
+ NavigationView {
+ List {
+ DataManagementSection()
+
+ AppInfoSection()
+ }
+ .navigationTitle("Settings")
+ }
+ }
+}
+
+struct DataManagementSection: 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 isExporting = false
+
+ var body: some View {
+ Section("Data Management") {
+ // Export Data
+ Button(action: {
+ exportDataAsync()
+ }) {
+ HStack {
+ if isExporting {
+ ProgressView()
+ .scaleEffect(0.8)
+ Text("Exporting...")
+ .foregroundColor(.secondary)
+ } else {
+ Image(systemName: "square.and.arrow.up")
+ .foregroundColor(.blue)
+ Text("Export Data")
+ }
+ Spacer()
+ }
+ }
+ .disabled(isExporting)
+ .foregroundColor(.primary)
+
+ // Import Data
+ Button(action: {
+ showingImportSheet = true
+ }) {
+ HStack {
+ Image(systemName: "square.and.arrow.down")
+ .foregroundColor(.green)
+ Text("Import Data")
+ Spacer()
+ }
+ }
+ .foregroundColor(.primary)
+
+ // Reset All Data
+ Button(action: {
+ showingResetAlert = true
+ }) {
+ HStack {
+ Image(systemName: "trash")
+ .foregroundColor(.red)
+ Text("Reset All Data")
+ Spacer()
+ }
+ }
+ .foregroundColor(.red)
+ }
+ .alert("Reset All Data", isPresented: $showingResetAlert) {
+ Button("Cancel", role: .cancel) {}
+ Button("Reset", role: .destructive) {
+ dataManager.resetAllData()
+ }
+ } message: {
+ Text(
+ "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
+ }
+ }
+ }
+ }
+}
+
+struct AppInfoSection: View {
+ private var appVersion: String {
+ Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "Unknown"
+ }
+
+ private var buildNumber: String {
+ Bundle.main.infoDictionary?["CFBundleVersion"] as? String ?? "Unknown"
+ }
+
+ var body: some View {
+ Section("App Information") {
+ HStack {
+ Image(systemName: "mountain.2.fill")
+ .foregroundColor(.blue)
+ VStack(alignment: .leading) {
+ Text("OpenClimb")
+ .font(.headline)
+ Text("Track your climbing progress")
+ .font(.caption)
+ .foregroundColor(.secondary)
+ }
+ Spacer()
+ }
+
+ HStack {
+ Image(systemName: "info.circle")
+ .foregroundColor(.blue)
+ Text("Version")
+ Spacer()
+ Text("\(appVersion) (\(buildNumber))")
+ .foregroundColor(.secondary)
+ }
+
+ HStack {
+ Image(systemName: "person.fill")
+ .foregroundColor(.blue)
+ Text("Developer")
+ Spacer()
+ Text("OpenClimb Team")
+ .foregroundColor(.secondary)
+ }
+ }
+ }
+}
+
+struct ExportDataView: View {
+ let data: Data
+ @Environment(\.dismiss) private var dismiss
+ @State private var tempFileURL: URL?
+
+ var body: some View {
+ NavigationView {
+ VStack(spacing: 20) {
+ Image(systemName: "square.and.arrow.up")
+ .font(.system(size: 60))
+ .foregroundColor(.blue)
+
+ Text("Export Data")
+ .font(.title)
+ .fontWeight(.bold)
+
+ Text(
+ "Your climbing data has been prepared for export. Use the share button below to save or send your data."
+ )
+ .multilineTextAlignment(.center)
+ .padding(.horizontal)
+
+ if let fileURL = tempFileURL {
+ ShareLink(
+ item: fileURL,
+ preview: SharePreview(
+ "OpenClimb Data Export",
+ image: Image(systemName: "mountain.2.fill"))
+ ) {
+ Label("Share Data", systemImage: "square.and.arrow.up")
+ .font(.headline)
+ .foregroundColor(.white)
+ .frame(maxWidth: .infinity)
+ .padding()
+ .background(
+ RoundedRectangle(cornerRadius: 12)
+ .fill(.blue)
+ )
+ }
+ .padding(.horizontal)
+ .buttonStyle(.plain)
+ } else {
+ Button(action: {}) {
+ Label("Preparing Export...", systemImage: "hourglass")
+ .font(.headline)
+ .foregroundColor(.white)
+ .frame(maxWidth: .infinity)
+ .padding()
+ .background(
+ RoundedRectangle(cornerRadius: 12)
+ .fill(.gray)
+ )
+ }
+ .disabled(true)
+ .padding(.horizontal)
+ }
+
+ Spacer()
+ }
+ .padding()
+ .navigationTitle("Export")
+ .navigationBarTitleDisplayMode(.inline)
+ .toolbar {
+ ToolbarItem(placement: .navigationBarTrailing) {
+ Button("Done") {
+ dismiss()
+ }
+ }
+ }
+ .onAppear {
+ if tempFileURL == nil {
+ createTempFile()
+ }
+ }
+ .onDisappear {
+ // Delay cleanup to ensure sharing is complete
+ DispatchQueue.main.asyncAfter(deadline: .now() + 1.0) {
+ cleanupTempFile()
+ }
+ }
+ }
+ }
+
+ private func createTempFile() {
+ DispatchQueue.global(qos: .userInitiated).async {
+ do {
+ let formatter = ISO8601DateFormatter()
+ formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
+ let isoString = formatter.string(from: Date())
+ let timestamp = isoString.replacingOccurrences(of: ":", with: "-")
+ .replacingOccurrences(of: ".", with: "-")
+ let filename = "openclimb_export_\(timestamp).zip"
+
+ guard
+ let documentsURL = FileManager.default.urls(
+ for: .documentDirectory, in: .userDomainMask
+ ).first
+ else {
+ print("Could not access Documents directory")
+ return
+ }
+ let fileURL = documentsURL.appendingPathComponent(filename)
+
+ // Write the ZIP data to the file
+ try data.write(to: fileURL)
+
+ DispatchQueue.main.async {
+ self.tempFileURL = fileURL
+ }
+ } catch {
+ print("Failed to create export file: \(error)")
+ }
+ }
+ }
+
+ private func cleanupTempFile() {
+ if let fileURL = tempFileURL {
+ // Clean up after a delay to ensure sharing is complete
+ DispatchQueue.main.asyncAfter(deadline: .now() + 5.0) {
+ try? FileManager.default.removeItem(at: fileURL)
+ print("Cleaned up export file: \(fileURL.lastPathComponent)")
+ }
+ }
+ }
+}
+
+struct ImportDataView: View {
+ @EnvironmentObject var dataManager: ClimbingDataManager
+ @Environment(\.dismiss) private var dismiss
+ @State private var isImporting = false
+ @State private var importError: String?
+ @State private var showingDocumentPicker = false
+
+ var body: some View {
+ NavigationView {
+ VStack(spacing: 20) {
+ Image(systemName: "square.and.arrow.down")
+ .font(.system(size: 60))
+ .foregroundColor(.green)
+
+ Text("Import Data")
+ .font(.title)
+ .fontWeight(.bold)
+
+ VStack(spacing: 12) {
+ 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)
+ .multilineTextAlignment(.center)
+ }
+ .padding(.horizontal)
+
+ Button(action: {
+ showingDocumentPicker = true
+ }) {
+ if isImporting {
+ HStack {
+ ProgressView()
+ .scaleEffect(0.8)
+ Text("Importing...")
+ }
+ } else {
+ Label("Select ZIP File to Import", systemImage: "folder.badge.plus")
+ }
+ }
+ .font(.headline)
+ .foregroundColor(.white)
+ .frame(maxWidth: .infinity)
+ .padding()
+ .background(
+ RoundedRectangle(cornerRadius: 12)
+ .fill(isImporting ? .gray : .green)
+ )
+ .padding(.horizontal)
+ .disabled(isImporting)
+
+ if let error = importError {
+ Text(error)
+ .foregroundColor(.red)
+ .padding()
+ .background(
+ RoundedRectangle(cornerRadius: 8)
+ .fill(.red.opacity(0.1))
+ )
+ }
+
+ Spacer()
+ }
+ .padding()
+ .navigationTitle("Import Data")
+ .navigationBarTitleDisplayMode(.inline)
+ .toolbar {
+ ToolbarItem(placement: .navigationBarTrailing) {
+ Button("Cancel") {
+ dismiss()
+ }
+ }
+ }
+ .fileImporter(
+ isPresented: $showingDocumentPicker,
+ allowedContentTypes: [.zip, .archive],
+ allowsMultipleSelection: false
+ ) { result in
+ switch result {
+ case .success(let urls):
+ if let url = urls.first {
+ importData(from: url)
+ }
+ case .failure(let error):
+ importError = "Failed to select file: \(error.localizedDescription)"
+ }
+ }
+ }
+ }
+
+ private func importData(from url: URL) {
+ isImporting = true
+ importError = nil
+
+ Task {
+ do {
+ // Access the security-scoped resource
+ guard url.startAccessingSecurityScopedResource() else {
+ await MainActor.run {
+ isImporting = false
+ importError = "Failed to access selected file"
+ }
+ return
+ }
+
+ defer { url.stopAccessingSecurityScopedResource() }
+
+ let data = try Data(contentsOf: url)
+ try dataManager.importData(from: data)
+
+ await MainActor.run {
+ isImporting = false
+ dismiss()
+ }
+ } catch {
+ await MainActor.run {
+ isImporting = false
+ importError = "Import failed: \(error.localizedDescription)"
+ }
+ }
+ }
+ }
+}
+
+#Preview {
+ SettingsView()
+ .environmentObject(ClimbingDataManager.preview)
+}
diff --git a/local.properties b/local.properties
deleted file mode 100644
index e3fa214..0000000
--- a/local.properties
+++ /dev/null
@@ -1,10 +0,0 @@
-## This file is automatically generated by Android Studio.
-# Do not modify this file -- YOUR CHANGES WILL BE ERASED!
-#
-# This file should *NOT* be checked into Version Control Systems,
-# as it contains information specific to your local configuration.
-#
-# Location of the SDK. This is only used by Gradle.
-# For customization when using a Version Control System, please read the
-# header note.
-sdk.dir=/Users/atridad/Library/Android/sdk
\ No newline at end of file