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