From 7da1893748ee46c7c2e6eca96982ff0e76f62522 Mon Sep 17 00:00:00 2001 From: Atridad Lahiji Date: Fri, 12 Sep 2025 22:35:14 -0600 Subject: [PATCH 1/3] 1.5.0 Initial run as iOS in a monorepo --- .idea/.gitignore | 3 - .idea/AndroidProjectSystem.xml | 6 - .idea/caches/deviceStreaming.xml | 860 ------------------ .idea/deploymentTargetSelector.xml | 18 - .idea/gradle.xml | 19 - .idea/inspectionProfiles/Project_Default.xml | 50 - .idea/kotlinc.xml | 6 - .idea/migrations.xml | 10 - .idea/misc.xml | 4 - .idea/runConfigurations.xml | 17 - .idea/vcs.xml | 6 - README.md | 6 +- {app => android/app}/.gitignore | 0 {app => android/app}/build.gradle.kts | 43 +- {app => android/app}/proguard-rules.pro | 0 .../openclimb/ExampleInstrumentedTest.kt | 0 .../app}/src/main/AndroidManifest.xml | 0 .../com/atridad/openclimb/MainActivity.kt | 0 .../openclimb/data/database/Converters.kt | 0 .../data/database/OpenClimbDatabase.kt | 0 .../openclimb/data/database/dao/AttemptDao.kt | 0 .../data/database/dao/ClimbSessionDao.kt | 0 .../openclimb/data/database/dao/GymDao.kt | 0 .../openclimb/data/database/dao/ProblemDao.kt | 0 .../atridad/openclimb/data/model/Attempt.kt | 0 .../openclimb/data/model/ClimbSession.kt | 0 .../atridad/openclimb/data/model/ClimbType.kt | 0 .../openclimb/data/model/DifficultySystem.kt | 0 .../com/atridad/openclimb/data/model/Gym.kt | 0 .../atridad/openclimb/data/model/Problem.kt | 0 .../data/repository/ClimbRepository.kt | 0 .../navigation/BottomNavigationItem.kt | 0 .../atridad/openclimb/navigation/Screen.kt | 0 .../service/SessionTrackingService.kt | 0 .../com/atridad/openclimb/ui/OpenClimbApp.kt | 0 .../ui/components/ActiveSessionBanner.kt | 0 .../ui/components/FullscreenImageViewer.kt | 0 .../openclimb/ui/components/ImageDisplay.kt | 0 .../openclimb/ui/components/ImagePicker.kt | 0 .../openclimb/ui/components/LineChart.kt | 0 .../NotificationPermissionDialog.kt | 0 .../openclimb/ui/screens/AddEditScreens.kt | 0 .../openclimb/ui/screens/AnalyticsScreen.kt | 0 .../openclimb/ui/screens/DetailScreens.kt | 0 .../openclimb/ui/screens/GymsScreen.kt | 0 .../openclimb/ui/screens/ProblemsScreen.kt | 0 .../openclimb/ui/screens/SessionsScreen.kt | 0 .../openclimb/ui/screens/SettingsScreen.kt | 0 .../com/atridad/openclimb/ui/theme/Color.kt | 0 .../atridad/openclimb/ui/theme/CustomIcons.kt | 0 .../com/atridad/openclimb/ui/theme/Theme.kt | 0 .../com/atridad/openclimb/ui/theme/Type.kt | 0 .../openclimb/ui/viewmodel/ClimbViewModel.kt | 0 .../ui/viewmodel/ClimbViewModelFactory.kt | 0 .../com/atridad/openclimb/utils/ImageUtils.kt | 0 .../utils/NotificationPermissionUtils.kt | 0 .../openclimb/utils/SessionShareUtils.kt | 0 .../openclimb/utils/ShortcutManager.kt | 0 .../openclimb/utils/ZipExportImportUtils.kt | 0 .../widget/ClimbStatsWidgetProvider.kt | 0 .../res/drawable-night/ic_play_arrow_24.xml | 0 .../main/res/drawable-night/ic_stop_24.xml | 0 .../res/drawable/ic_launcher_background.xml | 0 .../res/drawable/ic_launcher_foreground.xml | 0 .../src/main/res/drawable/ic_mountains.xml | 0 .../main/res/drawable/ic_play_arrow_24.xml | 0 .../app}/src/main/res/drawable/ic_stop_24.xml | 0 .../main/res/drawable/widget_background.xml | 0 .../drawable/widget_stat_card_background.xml | 0 .../res/drawable/widget_status_background.xml | 0 .../main/res/layout/widget_climb_stats.xml | 0 .../main/res/mipmap-anydpi/ic_launcher.xml | 0 .../res/mipmap-anydpi/ic_launcher_round.xml | 0 .../src/main/res/mipmap-hdpi/ic_launcher.webp | Bin .../res/mipmap-hdpi/ic_launcher_round.webp | Bin .../src/main/res/mipmap-mdpi/ic_launcher.webp | Bin .../res/mipmap-mdpi/ic_launcher_round.webp | Bin .../main/res/mipmap-xhdpi/ic_launcher.webp | Bin .../res/mipmap-xhdpi/ic_launcher_round.webp | Bin .../main/res/mipmap-xxhdpi/ic_launcher.webp | Bin .../res/mipmap-xxhdpi/ic_launcher_round.webp | Bin .../main/res/mipmap-xxxhdpi/ic_launcher.webp | Bin .../res/mipmap-xxxhdpi/ic_launcher_round.webp | Bin .../app}/src/main/res/values-night/colors.xml | 0 .../app}/src/main/res/values/colors.xml | 0 .../app}/src/main/res/values/strings.xml | 0 .../app}/src/main/res/values/themes.xml | 0 .../app}/src/main/res/xml/backup_rules.xml | 0 .../main/res/xml/data_extraction_rules.xml | 0 .../src/main/res/xml/file_provider_paths.xml | 0 .../main/res/xml/widget_climb_stats_info.xml | 0 .../com/atridad/openclimb/ExampleUnitTest.kt | 0 build.gradle.kts => android/build.gradle.kts | 0 .../gradle.properties | 0 {gradle => android/gradle}/libs.versions.toml | 0 .../gradle}/wrapper/gradle-wrapper.jar | Bin .../gradle}/wrapper/gradle-wrapper.properties | 0 gradlew => android/gradlew | 0 gradlew.bat => android/gradlew.bat | 0 .../settings.gradle.kts | 0 ios/OpenClimb.xcodeproj/project.pbxproj | 362 ++++++++ .../contents.xcworkspacedata | 7 + .../IDEFindNavigatorScopes.plist | 5 + .../UserInterfaceState.xcuserstate | Bin 0 -> 35145 bytes .../xcschemes/xcschememanagement.plist | 14 + .../AccentColor.colorset/Contents.json | 11 + .../AppIcon.appiconset/Contents.json | 35 + ios/OpenClimb/Assets.xcassets/Contents.json | 6 + ios/OpenClimb/ContentView.swift | 114 +++ ios/OpenClimb/Info.plist | 8 + ios/OpenClimb/Models/DataModels.swift | 561 ++++++++++++ ios/OpenClimb/OpenClimbApp.swift | 17 + ios/OpenClimb/Utils/ZipUtils.swift | 654 +++++++++++++ .../ViewModels/ClimbingDataManager.swift | 834 +++++++++++++++++ .../Views/AddEdit/AddAttemptView.swift | 554 +++++++++++ .../Views/AddEdit/AddEditGymView.swift | 216 +++++ .../Views/AddEdit/AddEditProblemView.swift | 529 +++++++++++ .../Views/AddEdit/AddEditSessionView.swift | 143 +++ ios/OpenClimb/Views/AnalyticsView.swift | 407 +++++++++ .../Views/Detail/GymDetailView.swift | 430 +++++++++ .../Views/Detail/ProblemDetailView.swift | 476 ++++++++++ .../Views/Detail/SessionDetailView.swift | 443 +++++++++ ios/OpenClimb/Views/GymsView.swift | 171 ++++ ios/OpenClimb/Views/ProblemsView.swift | 362 ++++++++ ios/OpenClimb/Views/SessionsView.swift | 243 +++++ ios/OpenClimb/Views/SettingsView.swift | 441 +++++++++ local.properties | 10 - 127 files changed, 7062 insertions(+), 1039 deletions(-) delete mode 100644 .idea/.gitignore delete mode 100644 .idea/AndroidProjectSystem.xml delete mode 100644 .idea/caches/deviceStreaming.xml delete mode 100644 .idea/deploymentTargetSelector.xml delete mode 100644 .idea/gradle.xml delete mode 100644 .idea/inspectionProfiles/Project_Default.xml delete mode 100644 .idea/kotlinc.xml delete mode 100644 .idea/migrations.xml delete mode 100644 .idea/misc.xml delete mode 100644 .idea/runConfigurations.xml delete mode 100644 .idea/vcs.xml rename {app => android/app}/.gitignore (100%) rename {app => android/app}/build.gradle.kts (86%) rename {app => android/app}/proguard-rules.pro (100%) rename {app => android/app}/src/androidTest/java/com/atridad/openclimb/ExampleInstrumentedTest.kt (100%) rename {app => android/app}/src/main/AndroidManifest.xml (100%) rename {app => android/app}/src/main/java/com/atridad/openclimb/MainActivity.kt (100%) rename {app => android/app}/src/main/java/com/atridad/openclimb/data/database/Converters.kt (100%) rename {app => android/app}/src/main/java/com/atridad/openclimb/data/database/OpenClimbDatabase.kt (100%) rename {app => android/app}/src/main/java/com/atridad/openclimb/data/database/dao/AttemptDao.kt (100%) rename {app => android/app}/src/main/java/com/atridad/openclimb/data/database/dao/ClimbSessionDao.kt (100%) rename {app => android/app}/src/main/java/com/atridad/openclimb/data/database/dao/GymDao.kt (100%) rename {app => android/app}/src/main/java/com/atridad/openclimb/data/database/dao/ProblemDao.kt (100%) rename {app => android/app}/src/main/java/com/atridad/openclimb/data/model/Attempt.kt (100%) rename {app => android/app}/src/main/java/com/atridad/openclimb/data/model/ClimbSession.kt (100%) rename {app => android/app}/src/main/java/com/atridad/openclimb/data/model/ClimbType.kt (100%) rename {app => android/app}/src/main/java/com/atridad/openclimb/data/model/DifficultySystem.kt (100%) rename {app => android/app}/src/main/java/com/atridad/openclimb/data/model/Gym.kt (100%) rename {app => android/app}/src/main/java/com/atridad/openclimb/data/model/Problem.kt (100%) rename {app => android/app}/src/main/java/com/atridad/openclimb/data/repository/ClimbRepository.kt (100%) rename {app => android/app}/src/main/java/com/atridad/openclimb/navigation/BottomNavigationItem.kt (100%) rename {app => android/app}/src/main/java/com/atridad/openclimb/navigation/Screen.kt (100%) rename {app => android/app}/src/main/java/com/atridad/openclimb/service/SessionTrackingService.kt (100%) rename {app => android/app}/src/main/java/com/atridad/openclimb/ui/OpenClimbApp.kt (100%) rename {app => android/app}/src/main/java/com/atridad/openclimb/ui/components/ActiveSessionBanner.kt (100%) rename {app => android/app}/src/main/java/com/atridad/openclimb/ui/components/FullscreenImageViewer.kt (100%) rename {app => android/app}/src/main/java/com/atridad/openclimb/ui/components/ImageDisplay.kt (100%) rename {app => android/app}/src/main/java/com/atridad/openclimb/ui/components/ImagePicker.kt (100%) rename {app => android/app}/src/main/java/com/atridad/openclimb/ui/components/LineChart.kt (100%) rename {app => android/app}/src/main/java/com/atridad/openclimb/ui/components/NotificationPermissionDialog.kt (100%) rename {app => android/app}/src/main/java/com/atridad/openclimb/ui/screens/AddEditScreens.kt (100%) rename {app => android/app}/src/main/java/com/atridad/openclimb/ui/screens/AnalyticsScreen.kt (100%) rename {app => android/app}/src/main/java/com/atridad/openclimb/ui/screens/DetailScreens.kt (100%) rename {app => android/app}/src/main/java/com/atridad/openclimb/ui/screens/GymsScreen.kt (100%) rename {app => android/app}/src/main/java/com/atridad/openclimb/ui/screens/ProblemsScreen.kt (100%) rename {app => android/app}/src/main/java/com/atridad/openclimb/ui/screens/SessionsScreen.kt (100%) rename {app => android/app}/src/main/java/com/atridad/openclimb/ui/screens/SettingsScreen.kt (100%) rename {app => android/app}/src/main/java/com/atridad/openclimb/ui/theme/Color.kt (100%) rename {app => android/app}/src/main/java/com/atridad/openclimb/ui/theme/CustomIcons.kt (100%) rename {app => android/app}/src/main/java/com/atridad/openclimb/ui/theme/Theme.kt (100%) rename {app => android/app}/src/main/java/com/atridad/openclimb/ui/theme/Type.kt (100%) rename {app => android/app}/src/main/java/com/atridad/openclimb/ui/viewmodel/ClimbViewModel.kt (100%) rename {app => android/app}/src/main/java/com/atridad/openclimb/ui/viewmodel/ClimbViewModelFactory.kt (100%) rename {app => android/app}/src/main/java/com/atridad/openclimb/utils/ImageUtils.kt (100%) rename {app => android/app}/src/main/java/com/atridad/openclimb/utils/NotificationPermissionUtils.kt (100%) rename {app => android/app}/src/main/java/com/atridad/openclimb/utils/SessionShareUtils.kt (100%) rename {app => android/app}/src/main/java/com/atridad/openclimb/utils/ShortcutManager.kt (100%) rename {app => android/app}/src/main/java/com/atridad/openclimb/utils/ZipExportImportUtils.kt (100%) rename {app => android/app}/src/main/java/com/atridad/openclimb/widget/ClimbStatsWidgetProvider.kt (100%) rename {app => android/app}/src/main/res/drawable-night/ic_play_arrow_24.xml (100%) rename {app => android/app}/src/main/res/drawable-night/ic_stop_24.xml (100%) rename {app => android/app}/src/main/res/drawable/ic_launcher_background.xml (100%) rename {app => android/app}/src/main/res/drawable/ic_launcher_foreground.xml (100%) rename {app => android/app}/src/main/res/drawable/ic_mountains.xml (100%) rename {app => android/app}/src/main/res/drawable/ic_play_arrow_24.xml (100%) rename {app => android/app}/src/main/res/drawable/ic_stop_24.xml (100%) rename {app => android/app}/src/main/res/drawable/widget_background.xml (100%) rename {app => android/app}/src/main/res/drawable/widget_stat_card_background.xml (100%) rename {app => android/app}/src/main/res/drawable/widget_status_background.xml (100%) rename {app => android/app}/src/main/res/layout/widget_climb_stats.xml (100%) rename {app => android/app}/src/main/res/mipmap-anydpi/ic_launcher.xml (100%) rename {app => android/app}/src/main/res/mipmap-anydpi/ic_launcher_round.xml (100%) rename {app => android/app}/src/main/res/mipmap-hdpi/ic_launcher.webp (100%) rename {app => android/app}/src/main/res/mipmap-hdpi/ic_launcher_round.webp (100%) rename {app => android/app}/src/main/res/mipmap-mdpi/ic_launcher.webp (100%) rename {app => android/app}/src/main/res/mipmap-mdpi/ic_launcher_round.webp (100%) rename {app => android/app}/src/main/res/mipmap-xhdpi/ic_launcher.webp (100%) rename {app => android/app}/src/main/res/mipmap-xhdpi/ic_launcher_round.webp (100%) rename {app => android/app}/src/main/res/mipmap-xxhdpi/ic_launcher.webp (100%) rename {app => android/app}/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp (100%) rename {app => android/app}/src/main/res/mipmap-xxxhdpi/ic_launcher.webp (100%) rename {app => android/app}/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp (100%) rename {app => android/app}/src/main/res/values-night/colors.xml (100%) rename {app => android/app}/src/main/res/values/colors.xml (100%) rename {app => android/app}/src/main/res/values/strings.xml (100%) rename {app => android/app}/src/main/res/values/themes.xml (100%) rename {app => android/app}/src/main/res/xml/backup_rules.xml (100%) rename {app => android/app}/src/main/res/xml/data_extraction_rules.xml (100%) rename {app => android/app}/src/main/res/xml/file_provider_paths.xml (100%) rename {app => android/app}/src/main/res/xml/widget_climb_stats_info.xml (100%) rename {app => android/app}/src/test/java/com/atridad/openclimb/ExampleUnitTest.kt (100%) rename build.gradle.kts => android/build.gradle.kts (100%) rename gradle.properties => android/gradle.properties (100%) rename {gradle => android/gradle}/libs.versions.toml (100%) rename {gradle => android/gradle}/wrapper/gradle-wrapper.jar (100%) rename {gradle => android/gradle}/wrapper/gradle-wrapper.properties (100%) rename gradlew => android/gradlew (100%) rename gradlew.bat => android/gradlew.bat (100%) rename settings.gradle.kts => android/settings.gradle.kts (100%) create mode 100644 ios/OpenClimb.xcodeproj/project.pbxproj create mode 100644 ios/OpenClimb.xcodeproj/project.xcworkspace/contents.xcworkspacedata create mode 100644 ios/OpenClimb.xcodeproj/project.xcworkspace/xcuserdata/atridad.xcuserdatad/IDEFindNavigatorScopes.plist create mode 100644 ios/OpenClimb.xcodeproj/project.xcworkspace/xcuserdata/atridad.xcuserdatad/UserInterfaceState.xcuserstate create mode 100644 ios/OpenClimb.xcodeproj/xcuserdata/atridad.xcuserdatad/xcschemes/xcschememanagement.plist create mode 100644 ios/OpenClimb/Assets.xcassets/AccentColor.colorset/Contents.json create mode 100644 ios/OpenClimb/Assets.xcassets/AppIcon.appiconset/Contents.json create mode 100644 ios/OpenClimb/Assets.xcassets/Contents.json create mode 100644 ios/OpenClimb/ContentView.swift create mode 100644 ios/OpenClimb/Info.plist create mode 100644 ios/OpenClimb/Models/DataModels.swift create mode 100644 ios/OpenClimb/OpenClimbApp.swift create mode 100644 ios/OpenClimb/Utils/ZipUtils.swift create mode 100644 ios/OpenClimb/ViewModels/ClimbingDataManager.swift create mode 100644 ios/OpenClimb/Views/AddEdit/AddAttemptView.swift create mode 100644 ios/OpenClimb/Views/AddEdit/AddEditGymView.swift create mode 100644 ios/OpenClimb/Views/AddEdit/AddEditProblemView.swift create mode 100644 ios/OpenClimb/Views/AddEdit/AddEditSessionView.swift create mode 100644 ios/OpenClimb/Views/AnalyticsView.swift create mode 100644 ios/OpenClimb/Views/Detail/GymDetailView.swift create mode 100644 ios/OpenClimb/Views/Detail/ProblemDetailView.swift create mode 100644 ios/OpenClimb/Views/Detail/SessionDetailView.swift create mode 100644 ios/OpenClimb/Views/GymsView.swift create mode 100644 ios/OpenClimb/Views/ProblemsView.swift create mode 100644 ios/OpenClimb/Views/SessionsView.swift create mode 100644 ios/OpenClimb/Views/SettingsView.swift delete mode 100644 local.properties 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 0000000000000000000000000000000000000000..9e7c81bcd8a9a00b981b953aafb9241e765a5976 GIT binary patch literal 35145 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 + + + + 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 From 61384623bdc735ee731b5fe5ff9914fc56129efe Mon Sep 17 00:00:00 2001 From: Atridad Lahiji Date: Sat, 13 Sep 2025 00:42:15 -0600 Subject: [PATCH 2/3] Import/export fixes, icon, and graphing --- .../UserInterfaceState.xcuserstate | Bin 35145 -> 39751 bytes .../AppIcon.appiconset/Contents.json | 41 +- .../AppIcon.appiconset/app_icon_1024.png | Bin 0 -> 22356 bytes .../MountainsIcon.imageset/Contents.json | 23 + .../mountains_icon_256.png | Bin 0 -> 5418 bytes ios/OpenClimb/Utils/ZipUtils.swift | 3 - .../Views/AddEdit/AddAttemptView.swift | 12 +- .../Views/AddEdit/AddEditGymView.swift | 2 +- .../Views/AddEdit/AddEditProblemView.swift | 17 +- ios/OpenClimb/Views/AnalyticsView.swift | 413 ++++++++++++------ ios/OpenClimb/Views/SettingsView.swift | 107 ++--- 11 files changed, 388 insertions(+), 230 deletions(-) create mode 100644 ios/OpenClimb/Assets.xcassets/AppIcon.appiconset/app_icon_1024.png create mode 100644 ios/OpenClimb/Assets.xcassets/MountainsIcon.imageset/Contents.json create mode 100644 ios/OpenClimb/Assets.xcassets/MountainsIcon.imageset/mountains_icon_256.png diff --git a/ios/OpenClimb.xcodeproj/project.xcworkspace/xcuserdata/atridad.xcuserdatad/UserInterfaceState.xcuserstate b/ios/OpenClimb.xcodeproj/project.xcworkspace/xcuserdata/atridad.xcuserdatad/UserInterfaceState.xcuserstate index 9e7c81bcd8a9a00b981b953aafb9241e765a5976..d9d3b262109998ac0672b5d331ee7247e77f8ace 100644 GIT binary patch literal 39751 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 ZJ3LuV+VJ;fDR>`X$JX6j(sXT4{SOp&kyHQx literal 0 HcmV?d00001 diff --git a/ios/OpenClimb/Assets.xcassets/MountainsIcon.imageset/Contents.json b/ios/OpenClimb/Assets.xcassets/MountainsIcon.imageset/Contents.json new file mode 100644 index 0000000..75a2160 --- /dev/null +++ b/ios/OpenClimb/Assets.xcassets/MountainsIcon.imageset/Contents.json @@ -0,0 +1,23 @@ +{ + "images": [ + { + "filename": "mountains_icon_256.png", + "idiom": "universal", + "scale": "1x" + }, + { + "filename": "mountains_icon_256.png", + "idiom": "universal", + "scale": "2x" + }, + { + "filename": "mountains_icon_256.png", + "idiom": "universal", + "scale": "3x" + } + ], + "info": { + "author": "xcode", + "version": 1 + } +} diff --git a/ios/OpenClimb/Assets.xcassets/MountainsIcon.imageset/mountains_icon_256.png b/ios/OpenClimb/Assets.xcassets/MountainsIcon.imageset/mountains_icon_256.png new file mode 100644 index 0000000000000000000000000000000000000000..209dd1c98b166cd568b4acc79b494ee1f1a2c0ae GIT binary patch literal 5418 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 some View { - if let gym = selectedGym { + if selectedGym != nil { Section("Climb Type") { ForEach(availableClimbTypes, id: \.self) { climbType in HStack { @@ -227,8 +227,13 @@ struct AddEditProblemView: View { .font(.headline) if selectedDifficultySystem == .custom || availableGrades.isEmpty { - TextField("Enter custom grade", text: $difficultyGrade) + TextField("Enter custom grade (numbers only)", text: $difficultyGrade) .textFieldStyle(.roundedBorder) + .keyboardType(.numberPad) + .onChange(of: difficultyGrade) { + // Filter out non-numeric characters + difficultyGrade = difficultyGrade.filter { $0.isNumber } + } } else { Menu { if !difficultyGrade.isEmpty { diff --git a/ios/OpenClimb/Views/AnalyticsView.swift b/ios/OpenClimb/Views/AnalyticsView.swift index 7a767b9..59e031e 100644 --- a/ios/OpenClimb/Views/AnalyticsView.swift +++ b/ios/OpenClimb/Views/AnalyticsView.swift @@ -20,9 +20,11 @@ struct AnalyticsView: View { ProgressChartSection() - FavoriteGymSection() + HStack(spacing: 16) { + FavoriteGymSection() - RecentActivitySection() + RecentActivitySection() + } } .padding() } @@ -34,9 +36,9 @@ struct AnalyticsView: View { struct HeaderSection: View { var body: some View { HStack { - Image(systemName: "mountain.2.fill") - .font(.title) - .foregroundColor(.blue) + Image("MountainsIcon") + .resizable() + .frame(width: 32, height: 32) Text("Analytics") .font(.title) @@ -133,7 +135,13 @@ struct ProgressChartSection: View { } private var usedSystems: [DifficultySystem] { - Set(progressData.map { $0.difficultySystem }).sorted { $0.rawValue < $1.rawValue } + let uniqueSystems = Set(progressData.map { $0.difficultySystem }) + return uniqueSystems.sorted { + let order: [DifficultySystem] = [.vScale, .font, .yds, .custom] + let firstIndex = order.firstIndex(of: $0) ?? order.count + let secondIndex = order.firstIndex(of: $1) ?? order.count + return firstIndex < secondIndex + } } var body: some View { @@ -148,20 +156,35 @@ struct ProgressChartSection: View { if usedSystems.count > 1 { Menu { ForEach(usedSystems, id: \.self) { system in - Button(system.displayName) { + Button(action: { selectedSystem = system + }) { + HStack { + Text(system.displayName) + if selectedSystem == system { + Spacer() + Image(systemName: "checkmark") + .foregroundColor(.blue) + } + } } } } label: { - Text(selectedSystem.displayName) - .font(.caption) - .padding(.horizontal, 8) - .padding(.vertical, 4) - .background( - RoundedRectangle(cornerRadius: 8) - .fill(.blue.opacity(0.1)) - ) - .foregroundColor(.blue) + HStack(spacing: 4) { + Text(selectedSystem.displayName) + .font(.subheadline) + .fontWeight(.medium) + Image(systemName: "chevron.down") + .font(.caption) + } + .padding(.horizontal, 12) + .padding(.vertical, 6) + .background( + RoundedRectangle(cornerRadius: 8) + .fill(.blue.opacity(0.1)) + .stroke(.blue.opacity(0.3), lineWidth: 1) + ) + .foregroundColor(.blue) } } } @@ -169,37 +192,11 @@ struct ProgressChartSection: View { let filteredData = progressData.filter { $0.difficultySystem == selectedSystem } if !filteredData.isEmpty { - VStack { - // Simple text-based chart placeholder - VStack(alignment: .leading, spacing: 8) { - ForEach(filteredData.indices.prefix(5), id: \.self) { index in - let point = filteredData[index] - HStack { - Text("Session \(index + 1)") - .font(.caption) - .frame(width: 80, alignment: .leading) - - Rectangle() - .fill(.blue) - .frame(width: CGFloat(point.maxGradeNumeric * 5), height: 20) - - Text(point.maxGrade) - .font(.caption) - .foregroundColor(.blue) - } - } - - if filteredData.count > 5 { - Text("... and \(filteredData.count - 5) more sessions") - .font(.caption) - .foregroundColor(.secondary) - } - } - } - .frame(height: 200) + LineChartView(data: filteredData, selectedSystem: selectedSystem) + .frame(height: 200) Text( - "X: session number, Y: max \(selectedSystem.displayName.lowercased()) grade achieved" + "Progress: max \(selectedSystem.displayName.lowercased()) grade achieved per session" ) .font(.caption) .foregroundColor(.secondary) @@ -239,22 +236,28 @@ struct ProgressChartSection: View { let attemptedProblemIds = sessionAttempts.map { $0.problemId } let attemptedProblems = problems.filter { attemptedProblemIds.contains($0.id) } - guard - let highestGradeProblem = attemptedProblems.max(by: { - $0.difficulty.numericValue < $1.difficulty.numericValue - }) - else { - return nil - } + // Group problems by difficulty system + let problemsBySystem = Dictionary(grouping: attemptedProblems) { $0.difficulty.system } - return ProgressDataPoint( - date: session.date, - maxGrade: highestGradeProblem.difficulty.grade, - maxGradeNumeric: highestGradeProblem.difficulty.numericValue, - climbType: highestGradeProblem.climbType, - difficultySystem: highestGradeProblem.difficulty.system - ) - } + // Create data points for each system used in this session + return problemsBySystem.compactMap { (system, systemProblems) -> ProgressDataPoint? in + guard + let highestGradeProblem = systemProblems.max(by: { + $0.difficulty.numericValue < $1.difficulty.numericValue + }) + else { + return nil + } + + return ProgressDataPoint( + date: session.date, + maxGrade: highestGradeProblem.difficulty.grade, + maxGradeNumeric: highestGradeProblem.difficulty.numericValue, + climbType: highestGradeProblem.climbType, + difficultySystem: system + ) + } + }.flatMap { $0 } } } @@ -275,27 +278,53 @@ struct FavoriteGymSection: View { } var body: some View { - VStack(alignment: .leading, spacing: 12) { - Text("Favorite Gym") - .font(.title2) - .fontWeight(.bold) + VStack(alignment: .leading, spacing: 16) { + HStack { + Image(systemName: "location.fill") + .font(.title2) + .foregroundColor(.purple) + + Text("Favorite Gym") + .font(.title2) + .fontWeight(.bold) + + Spacer() + } if let info = favoriteGymInfo { - VStack(alignment: .leading, spacing: 8) { + VStack(alignment: .leading, spacing: 12) { Text(info.gym.name) .font(.title3) .fontWeight(.semibold) + .foregroundColor(.primary) - Text("\(info.sessionCount) sessions") - .font(.subheadline) - .foregroundColor(.secondary) + HStack { + Image(systemName: "calendar") + .font(.subheadline) + .foregroundColor(.purple) + + Text("\(info.sessionCount) sessions") + .font(.subheadline) + .foregroundColor(.secondary) + } + + Spacer() } } else { - Text("No sessions yet") - .font(.subheadline) - .foregroundColor(.secondary) + VStack(alignment: .leading, spacing: 8) { + Text("No sessions yet") + .font(.subheadline) + .foregroundColor(.secondary) + + Text("Start climbing to see your favorite gym!") + .font(.caption) + .foregroundColor(.secondary) + + Spacer() + } } } + .frame(maxWidth: .infinity, minHeight: 120, alignment: .topLeading) .padding() .background( RoundedRectangle(cornerRadius: 16) @@ -311,21 +340,63 @@ struct RecentActivitySection: View { dataManager.sessions.count } + private var totalAttempts: Int { + dataManager.attempts.count + } + var body: some View { - VStack(alignment: .leading, spacing: 12) { - Text("Recent Activity") - .font(.title2) - .fontWeight(.bold) + VStack(alignment: .leading, spacing: 16) { + HStack { + Image(systemName: "clock.fill") + .font(.title2) + .foregroundColor(.blue) + + Text("Recent Activity") + .font(.title2) + .fontWeight(.bold) + + Spacer() + } if recentSessionsCount > 0 { - Text("You've had \(recentSessionsCount) sessions") - .font(.subheadline) + VStack(alignment: .leading, spacing: 12) { + HStack { + Image(systemName: "play.circle") + .font(.subheadline) + .foregroundColor(.blue) + + Text("\(recentSessionsCount) sessions") + .font(.subheadline) + .foregroundColor(.secondary) + } + + HStack { + Image(systemName: "hand.raised") + .font(.subheadline) + .foregroundColor(.green) + + Text("\(totalAttempts) attempts") + .font(.subheadline) + .foregroundColor(.secondary) + } + + Spacer() + } } else { - Text("No recent activity") - .font(.subheadline) - .foregroundColor(.secondary) + VStack(alignment: .leading, spacing: 8) { + Text("No recent activity") + .font(.subheadline) + .foregroundColor(.secondary) + + Text("Start your first session!") + .font(.caption) + .foregroundColor(.secondary) + + Spacer() + } } } + .frame(maxWidth: .infinity, minHeight: 120, alignment: .topLeading) .padding() .background( RoundedRectangle(cornerRadius: 16) @@ -334,6 +405,131 @@ struct RecentActivitySection: View { } } +struct LineChartView: View { + let data: [ProgressDataPoint] + let selectedSystem: DifficultySystem + + private var uniqueGrades: [String] { + if selectedSystem == .custom { + return Array(Set(data.map { $0.maxGrade })).sorted { grade1, grade2 in + return (Int(grade1) ?? 0) > (Int(grade2) ?? 0) + } + } else { + return Array(Set(data.map { $0.maxGrade })).sorted { grade1, grade2 in + let grade1Data = data.first(where: { $0.maxGrade == grade1 }) + let grade2Data = data.first(where: { $0.maxGrade == grade2 }) + return (grade1Data?.maxGradeNumeric ?? 0) + > (grade2Data?.maxGradeNumeric ?? 0) + } + } + } + + private var minGrade: Int { + data.map { $0.maxGradeNumeric }.min() ?? 0 + } + + private var maxGrade: Int { + data.map { $0.maxGradeNumeric }.max() ?? 1 + } + + private var gradeRange: Int { + max(maxGrade - minGrade, 1) + } + + var body: some View { + GeometryReader { geometry in + let chartWidth = geometry.size.width - 40 + let chartHeight = geometry.size.height - 40 + + if data.isEmpty { + Rectangle() + .fill(.clear) + .overlay( + Text("No data") + .foregroundColor(.secondary) + ) + } else { + + HStack { + // Y-axis labels + VStack { + ForEach(0.. 1 { + Path { path in + for (index, point) in data.enumerated() { + let x = CGFloat(index) * chartWidth / CGFloat(data.count - 1) + let normalizedY = + CGFloat(point.maxGradeNumeric - minGrade) + / CGFloat(gradeRange) + let y = chartHeight - (normalizedY * chartHeight) + + if index == 0 { + path.move(to: CGPoint(x: x, y: y)) + } else { + path.addLine(to: CGPoint(x: x, y: y)) + } + } + } + .stroke(.blue, lineWidth: 2) + } + + // Data points + ForEach(data.indices, id: \.self) { index in + let point = data[index] + let x = + data.count == 1 + ? chartWidth / 2 + : CGFloat(index) * chartWidth / CGFloat(data.count - 1) + let normalizedY = + CGFloat(point.maxGradeNumeric - minGrade) / CGFloat(gradeRange) + let y = chartHeight - (normalizedY * chartHeight) + + Circle() + .fill(.blue) + .frame(width: 8, height: 8) + .position(x: x, y: y) + .overlay( + Circle() + .stroke(.white, lineWidth: 2) + .frame(width: 8, height: 8) + .position(x: x, y: y) + ) + } + } + .frame(width: chartWidth, height: chartHeight) + } + } + } + .padding() + } +} + struct ProgressDataPoint { let date: Date let maxGrade: String @@ -344,63 +540,6 @@ struct ProgressDataPoint { // MARK: - Helper Functions -func gradeToNumeric(_ system: DifficultySystem, _ grade: String) -> Int { - switch system { - case .vScale: - if grade == "VB" { return 0 } - return Int(grade.replacingOccurrences(of: "V", with: "")) ?? 0 - case .font: - let fontMapping: [String: Int] = [ - "3": 3, "4A": 4, "4B": 5, "4C": 6, "5A": 7, "5B": 8, "5C": 9, - "6A": 10, "6A+": 11, "6B": 12, "6B+": 13, "6C": 14, "6C+": 15, - "7A": 16, "7A+": 17, "7B": 18, "7B+": 19, "7C": 20, "7C+": 21, - "8A": 22, "8A+": 23, "8B": 24, "8B+": 25, "8C": 26, "8C+": 27, - ] - return fontMapping[grade] ?? 0 - case .yds: - let ydsMapping: [String: Int] = [ - "5.0": 50, "5.1": 51, "5.2": 52, "5.3": 53, "5.4": 54, "5.5": 55, - "5.6": 56, "5.7": 57, "5.8": 58, "5.9": 59, "5.10a": 60, "5.10b": 61, - "5.10c": 62, "5.10d": 63, "5.11a": 64, "5.11b": 65, "5.11c": 66, - "5.11d": 67, "5.12a": 68, "5.12b": 69, "5.12c": 70, "5.12d": 71, - "5.13a": 72, "5.13b": 73, "5.13c": 74, "5.13d": 75, "5.14a": 76, - "5.14b": 77, "5.14c": 78, "5.14d": 79, "5.15a": 80, "5.15b": 81, - "5.15c": 82, "5.15d": 83, - ] - return ydsMapping[grade] ?? 0 - case .custom: - return Int(grade) ?? 0 - } -} - -func numericToGrade(_ system: DifficultySystem, _ numeric: Int) -> String { - switch system { - case .vScale: - return numeric == 0 ? "VB" : "V\(numeric)" - case .font: - let fontMapping: [Int: String] = [ - 3: "3", 4: "4A", 5: "4B", 6: "4C", 7: "5A", 8: "5B", 9: "5C", - 10: "6A", 11: "6A+", 12: "6B", 13: "6B+", 14: "6C", 15: "6C+", - 16: "7A", 17: "7A+", 18: "7B", 19: "7B+", 20: "7C", 21: "7C+", - 22: "8A", 23: "8A+", 24: "8B", 25: "8B+", 26: "8C", 27: "8C+", - ] - return fontMapping[numeric] ?? "\(numeric)" - case .yds: - let ydsMapping: [Int: String] = [ - 50: "5.0", 51: "5.1", 52: "5.2", 53: "5.3", 54: "5.4", 55: "5.5", - 56: "5.6", 57: "5.7", 58: "5.8", 59: "5.9", 60: "5.10a", 61: "5.10b", - 62: "5.10c", 63: "5.10d", 64: "5.11a", 65: "5.11b", 66: "5.11c", - 67: "5.11d", 68: "5.12a", 69: "5.12b", 70: "5.12c", 71: "5.12d", - 72: "5.13a", 73: "5.13b", 74: "5.13c", 75: "5.13d", 76: "5.14a", - 77: "5.14b", 78: "5.14c", 79: "5.14d", 80: "5.15a", 81: "5.15b", - 82: "5.15c", 83: "5.15d", - ] - return ydsMapping[numeric] ?? "\(numeric)" - case .custom: - return "\(numeric)" - } -} - #Preview { AnalyticsView() .environmentObject(ClimbingDataManager.preview) diff --git a/ios/OpenClimb/Views/SettingsView.swift b/ios/OpenClimb/Views/SettingsView.swift index 91866fe..018e9e3 100644 --- a/ios/OpenClimb/Views/SettingsView.swift +++ b/ios/OpenClimb/Views/SettingsView.swift @@ -8,31 +8,53 @@ import SwiftUI import UniformTypeIdentifiers +enum SheetType { + case export(Data) + case importData +} + struct SettingsView: View { @EnvironmentObject var dataManager: ClimbingDataManager - @State private var showingResetAlert = false - @State private var showingExportSheet = false - @State private var showingImportSheet = false - @State private var exportData: Data? + @State private var activeSheet: SheetType? var body: some View { - NavigationView { - List { - DataManagementSection() + List { + DataManagementSection( + activeSheet: $activeSheet + ) - AppInfoSection() + AppInfoSection() + } + .navigationTitle("Settings") + .sheet( + item: Binding( + get: { activeSheet }, + set: { activeSheet = $0 } + ) + ) { sheetType in + switch sheetType { + case .export(let data): + ExportDataView(data: data) + case .importData: + ImportDataView() } - .navigationTitle("Settings") + } + } +} + +extension SheetType: Identifiable { + var id: String { + switch self { + case .export: return "export" + case .importData: return "import" } } } struct DataManagementSection: View { @EnvironmentObject var dataManager: ClimbingDataManager + @Binding var activeSheet: SheetType? @State private var showingResetAlert = false - @State private var showingExportSheet = false - @State private var showingImportSheet = false - @State private var exportData: Data? @State private var isExporting = false var body: some View { @@ -60,7 +82,7 @@ struct DataManagementSection: View { // Import Data Button(action: { - showingImportSheet = true + activeSheet = .importData }) { HStack { Image(systemName: "square.and.arrow.down") @@ -94,38 +116,15 @@ struct DataManagementSection: View { "Are you sure you want to reset all data? This will permanently delete:\n\n• All gyms and their information\n• All problems and their images\n• All climbing sessions\n• All attempts and progress data\n\nThis action cannot be undone. Consider exporting your data first." ) } - .sheet(isPresented: $showingExportSheet) { - if let data = exportData { - ExportDataView(data: data) - } else { - Text("No export data available") - } - } - .sheet(isPresented: $showingImportSheet) { - ImportDataView() - } } private func exportDataAsync() { isExporting = true - Task { - let data = await withCheckedContinuation { continuation in - DispatchQueue.global(qos: .userInitiated).async { - let result = dataManager.exportData() - continuation.resume(returning: result) - } - } - - await MainActor.run { - isExporting = false - if let data = data { - exportData = data - showingExportSheet = true - } else { - // Error message should already be set by dataManager - exportData = nil - } + let data = await MainActor.run { dataManager.exportData() } + isExporting = false + if let data = data { + activeSheet = .export(data) } } } @@ -143,8 +142,9 @@ struct AppInfoSection: View { var body: some View { Section("App Information") { HStack { - Image(systemName: "mountain.2.fill") - .foregroundColor(.blue) + Image("MountainsIcon") + .resizable() + .frame(width: 24, height: 24) VStack(alignment: .leading) { Text("OpenClimb") .font(.headline) @@ -163,15 +163,6 @@ struct AppInfoSection: View { Text("\(appVersion) (\(buildNumber))") .foregroundColor(.secondary) } - - HStack { - Image(systemName: "person.fill") - .foregroundColor(.blue) - Text("Developer") - Spacer() - Text("OpenClimb Team") - .foregroundColor(.secondary) - } } } } @@ -203,7 +194,7 @@ struct ExportDataView: View { item: fileURL, preview: SharePreview( "OpenClimb Data Export", - image: Image(systemName: "mountain.2.fill")) + image: Image("MountainsIcon")) ) { Label("Share Data", systemImage: "square.and.arrow.up") .font(.headline) @@ -324,13 +315,6 @@ struct ImportDataView: View { Text("Import climbing data from a previously exported ZIP file.") .multilineTextAlignment(.center) - Text( - "Fully compatible with Android exports - identical ZIP format with images." - ) - .font(.subheadline) - .foregroundColor(.blue) - .multilineTextAlignment(.center) - Text("⚠️ Warning: This will replace all current data!") .font(.subheadline) .foregroundColor(.red) @@ -423,7 +407,10 @@ struct ImportDataView: View { await MainActor.run { isImporting = false - dismiss() + // Auto-close after successful import + DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { + dismiss() + } } } catch { await MainActor.run { From ff9f0d6cc628d03a8a3e281ff443e436417d8018 Mon Sep 17 00:00:00 2001 From: Atridad Lahiji Date: Sun, 14 Sep 2025 23:07:32 -0600 Subject: [PATCH 3/3] 1.0.0 for iOS is ready to ship --- README.md | 11 +- ios/OpenClimb.xcodeproj/project.pbxproj | 4 +- .../UserInterfaceState.xcuserstate | Bin 39751 -> 47577 bytes .../AppIcon.appiconset/Contents.json | 4 +- .../AppIcon.appiconset/app_icon_1024.png | Bin 22356 -> 21992 bytes .../AppIcon.appiconset/app_icon_1024_dark.png | Bin 0 -> 37009 bytes .../app_icon_1024_tinted.png | Bin 0 -> 12443 bytes .../app_icon_dark_template.svg | 18 + .../app_icon_light_template.svg | 18 + .../app_icon_tinted_template.svg | 20 + .../MountainsIcon.imageset/Contents.json | 59 +- .../mountains_icon_256.png | Bin 5418 -> 4100 bytes .../mountains_icon_256_dark.png | Bin 0 -> 3761 bytes ios/OpenClimb/ContentView.swift | 6 - ios/OpenClimb/Models/DataModels.swift | 6 - ios/OpenClimb/OpenClimbApp.swift | 6 - ios/OpenClimb/Utils/AppIconHelper.swift | 116 +++ ios/OpenClimb/Utils/IconTestView.swift | 579 ++++++++++++ ios/OpenClimb/Utils/ImageManager.swift | 854 ++++++++++++++++++ ios/OpenClimb/Utils/ZipUtils.swift | 24 +- .../ViewModels/ClimbingDataManager.swift | 152 +++- .../Views/AddEdit/AddAttemptView.swift | 343 ++++++- .../Views/AddEdit/AddEditGymView.swift | 6 - .../Views/AddEdit/AddEditProblemView.swift | 21 +- .../Views/AddEdit/AddEditSessionView.swift | 6 - ios/OpenClimb/Views/AnalyticsView.swift | 9 - .../Views/Detail/GymDetailView.swift | 6 - .../Views/Detail/ProblemDetailView.swift | 163 +++- .../Views/Detail/SessionDetailView.swift | 145 ++- ios/OpenClimb/Views/GymsView.swift | 43 +- ios/OpenClimb/Views/ProblemsView.swift | 148 ++- ios/OpenClimb/Views/SessionsView.swift | 32 +- ios/OpenClimb/Views/SettingsView.swift | 98 +- 33 files changed, 2646 insertions(+), 251 deletions(-) create mode 100644 ios/OpenClimb/Assets.xcassets/AppIcon.appiconset/app_icon_1024_dark.png create mode 100644 ios/OpenClimb/Assets.xcassets/AppIcon.appiconset/app_icon_1024_tinted.png create mode 100644 ios/OpenClimb/Assets.xcassets/AppIcon.appiconset/app_icon_dark_template.svg create mode 100644 ios/OpenClimb/Assets.xcassets/AppIcon.appiconset/app_icon_light_template.svg create mode 100644 ios/OpenClimb/Assets.xcassets/AppIcon.appiconset/app_icon_tinted_template.svg create mode 100644 ios/OpenClimb/Assets.xcassets/MountainsIcon.imageset/mountains_icon_256_dark.png create mode 100644 ios/OpenClimb/Utils/AppIconHelper.swift create mode 100644 ios/OpenClimb/Utils/IconTestView.swift create mode 100644 ios/OpenClimb/Utils/ImageManager.swift diff --git a/README.md b/README.md index 71be771..f52bac5 100644 --- a/README.md +++ b/README.md @@ -2,13 +2,22 @@ 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. +## Versions + +Android:1.4.2 +iOS: 1.0.0 + ## Download -You have two options: +For Android do one of the following: 1. Download the latest APK from the Releases page 2. [Obtainium](https://apps.obtainium.imranr.dev/redirect?r=obtainium://app/%7B%22id%22%3A%22com.atridad.openclimb%22%2C%22url%22%3A%22https%3A%2F%2Fgit.atri.dad%2Fatridad%2FOpenClimb%2Freleases%22%2C%22author%22%3A%22git.atri.dad%22%2C%22name%22%3A%22OpenClimb%22%2C%22preferredApkIndex%22%3A0%2C%22additionalSettings%22%3A%22%7B%5C%22intermediateLink%5C%22%3A%5B%5D%2C%5C%22customLinkFilterRegex%5C%22%3A%5C%22%5C%22%2C%5C%22filterByLinkText%5C%22%3Afalse%2C%5C%22skipSort%5C%22%3Afalse%2C%5C%22reverseSort%5C%22%3Afalse%2C%5C%22sortByLastLinkSegment%5C%22%3Afalse%2C%5C%22versionExtractWholePage%5C%22%3Afalse%2C%5C%22requestHeader%5C%22%3A%5B%7B%5C%22requestHeader%5C%22%3A%5C%22User-Agent%3A%20Mozilla%2F5.0%20(Linux%3B%20Android%2010%3B%20K)%20AppleWebKit%2F537.36%20(KHTML%2C%20like%20Gecko)%20Chrome%2F114.0.0.0%20Mobile%20Safari%2F537.36%5C%22%7D%5D%2C%5C%22defaultPseudoVersioningMethod%5C%22%3A%5C%22partialAPKHash%5C%22%2C%5C%22trackOnly%5C%22%3Afalse%2C%5C%22versionExtractionRegEx%5C%22%3A%5C%22%5C%22%2C%5C%22matchGroupToUse%5C%22%3A%5C%22%5C%22%2C%5C%22versionDetection%5C%22%3Afalse%2C%5C%22useVersionCodeAsOSVersion%5C%22%3Afalse%2C%5C%22apkFilterRegEx%5C%22%3A%5C%22%5C%22%2C%5C%22invertAPKFilter%5C%22%3Afalse%2C%5C%22autoApkFilterByArch%5C%22%3Atrue%2C%5C%22appName%5C%22%3A%5C%22OpenClimb%5C%22%2C%5C%22appAuthor%5C%22%3A%5C%22%5C%22%2C%5C%22shizukuPretendToBeGooglePlay%5C%22%3Afalse%2C%5C%22allowInsecure%5C%22%3Afalse%2C%5C%22exemptFromBackgroundUpdates%5C%22%3Afalse%2C%5C%22skipUpdateNotifications%5C%22%3Afalse%2C%5C%22about%5C%22%3A%5C%22%5C%22%2C%5C%22refreshBeforeDownload%5C%22%3Afalse%7D%22%2C%22overrideSource%22%3Anull%7D) +For iOS: + +**Stay tuned for an upcoming Testflight or App Store release!** + ## Requirements - Android 12+ or iOS 17+ diff --git a/ios/OpenClimb.xcodeproj/project.pbxproj b/ios/OpenClimb.xcodeproj/project.pbxproj index bbc788c..2543c0a 100644 --- a/ios/OpenClimb.xcodeproj/project.pbxproj +++ b/ios/OpenClimb.xcodeproj/project.pbxproj @@ -285,7 +285,7 @@ "$(inherited)", "@executable_path/Frameworks", ); - MARKETING_VERSION = 1.5.0; + MARKETING_VERSION = 1.0.0; PRODUCT_BUNDLE_IDENTIFIER = com.atridad.OpenClimb; PRODUCT_NAME = "$(TARGET_NAME)"; STRING_CATALOG_GENERATE_SYMBOLS = YES; @@ -323,7 +323,7 @@ "$(inherited)", "@executable_path/Frameworks", ); - MARKETING_VERSION = 1.5.0; + MARKETING_VERSION = 1.0.0; PRODUCT_BUNDLE_IDENTIFIER = com.atridad.OpenClimb; PRODUCT_NAME = "$(TARGET_NAME)"; STRING_CATALOG_GENERATE_SYMBOLS = YES; diff --git a/ios/OpenClimb.xcodeproj/project.xcworkspace/xcuserdata/atridad.xcuserdatad/UserInterfaceState.xcuserstate b/ios/OpenClimb.xcodeproj/project.xcworkspace/xcuserdata/atridad.xcuserdatad/UserInterfaceState.xcuserstate index d9d3b262109998ac0672b5d331ee7247e77f8ace..dac7ec28142cb329ceb48e874fe90f0ae0eb0429 100644 GIT binary patch literal 47577 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 AssI20 delta 20306 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 diff --git a/ios/OpenClimb/Assets.xcassets/AppIcon.appiconset/Contents.json b/ios/OpenClimb/Assets.xcassets/AppIcon.appiconset/Contents.json index 866ce16..428b184 100644 --- a/ios/OpenClimb/Assets.xcassets/AppIcon.appiconset/Contents.json +++ b/ios/OpenClimb/Assets.xcassets/AppIcon.appiconset/Contents.json @@ -13,7 +13,7 @@ "value": "dark" } ], - "filename": "app_icon_1024.png", + "filename": "app_icon_1024_dark.png", "idiom": "universal", "platform": "ios", "size": "1024x1024" @@ -25,7 +25,7 @@ "value": "tinted" } ], - "filename": "app_icon_1024.png", + "filename": "app_icon_1024_tinted.png", "idiom": "universal", "platform": "ios", "size": "1024x1024" diff --git a/ios/OpenClimb/Assets.xcassets/AppIcon.appiconset/app_icon_1024.png b/ios/OpenClimb/Assets.xcassets/AppIcon.appiconset/app_icon_1024.png index 1b6fdd3bb580107f3b4f0bf4aa56fd2c7603cfb4..f6854e3c73191f434eaa49b8b7ac549db9991a28 100644 GIT binary patch literal 21992 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 Lxi#Z2htvND8Bbfd literal 22356 zcmeHvc_376^#3zdmJ%ft3U6-;k&2KouNDzu`HyW7k%mAAACH1XhZ^>G}YG*~1-M{xLmKPj4h0i5NWD zG1~3odh5c|W9}PooPTSOd28aM@~tD|dKoH0iR+fcziYUWa+ObK#8wK-4W1)x>@R6m4$&hreIp z-+*r&{sZyUJp6}n=|6|So%pja$Q}MT0~Qp2oZ*i%{6S3Q1Ao#41_*zg;g2)?$&FYj z{C|@hi5Dko%2uWq1$U$apps}6>9{|#v=Z(?{H>R}$81QW_4{Zf!`p)-#!%Hqdocn~ zuGvI@e-4k_I-F*Cy0o_PYGa=~DRt~l-F^TJR1wW48t;61u1ZpUkVfJGz4lx7%dk1X*2@p8fEW=`F$}tmS)&af zAU<6W&ji&uUaQ3RdN}VNENLA3YDjX_xZ!Y)8z|>tb;0q~hw~o!dAj&bjD9?K(YNoX zLQ!U~N2xmxTyhCIy|b?Fl;W9PsV03M1Yl${CS(imF#pEV@t&mC_lEVc0fRTC4Cu=@ zRp<1*R%?2r82zjBO?zGX96;{u!|vQ#e)kt|zW|elEATNC@msphwsVY*=MBTWmwK&p z<~S7n4M0j2x~!2CzJcbqr33)EzSRl+vii!?QjyYz?_Wzddl%U>3N{3Of7j;mR5;eH zzTunic>pf-V3%xMZ1cr?tS)V21Xe>YzG9o|uN-syV1=G#EaUL)7apHJ#&o;?4EXEK zo7C5Cq2s3rAlD`qU4p_*TXA1=)I8}Iv}LsT9QZ4;h53A1NvD9~&jZnP!{0xg5&()MxgGJ6IWd|hF7$54iKhC*ioKOnE^ zzG>9 zO~bhrcO|K_0Xh8;y4&*{ewbldXpr>CSc2{EM~*y;EV)-(C*o4e1yt7Q%vua9{aYNH zDD6}6)Q{rPaowZfrloRQ&CRzNmH-UdV}?C{Pu;DG*0n1`LHvOvh(8KX2f%*cjqZHa zeMh<^KWnY=Dc{l{XM=vR7+rS_eqZs`0Yh(2dS@sUz+Jd8jBUQjVYWan+npTNzWLlT z($70RM?SX87ExybiVSwmXydlS0bx>KM5rYSTwr_LN_5S8<({=y6{Xz#^3xTcdEB14 zg+yNb)OENYmZtS|0w8_{0gkQwjp-dUh%~gZhM}ATfDm+F_*edeieeiCl@zN;q!FXF zJ{1R(3-ZeqLZMlRyqF)#DdT=f=TBb*;xmS1is5P3S80y4>OW72d!jYSSg&)waozD{ zonMB(NqDr2zBzIHkT+cvkYCa;>+dj)$?oVKxK}4~Kot;|WA1Md#um%=_YJZ%)BX1p z{;ajd&U1uU0MUz>p4iF@5mpIz#QFhI^F`hXm&63$jtA3k4BZl3>OYq1Tk?3?~m38{=7XI*Bmc0wShQ2|8_Vxn?R$FHv2kw3X4m)k_3lZQBTvD1Ii5c=a_()O zsK|~Yogm6$YLci~m;r57sXGBUFT(awezE1Fq({lB@Q%E1z(ub~aX|<7w8>16Dpc7t zvg3<)w|3v@I>r9hyM=cQ3x6GQe|Fj2X+s%YVf#(Y2N?QC>tidv+_pEb7S@{wY+W&L zly${6E+fnP;9xrPq1(r;Aly<}}%_rZ#OxwG~ihj|b_MHF>Heh?%($vKf)cvW$ z@9vg0`>)*e(0&wd zXfC|xdaJL|EyytBW}a$ZD%zJ1_+ADW9q`J3OfsWitX5Bfj`Iwy=$XT|m&ScR_9#Md zg!_LCdm_fpS}(i4mJhvSgZBkR9C8xxd6_Ecsr$?Y#Elpx_&PGfTFyHJ-e!d$VGf9X z^pznv**)1`bbI2^%gF^)p6Jz|-wWD^FB?zn%JYpI{)Zm+2tZg9e?MWe}Y>$RtMH3donnW*A8r5Au)KXK=r|Qz3 z533lGT{x7&C-1xs{l%7R+d$W*Au|%s_J5KGdv0=f*qf{TrMC=1F$M>Ne`{J=h?ZQq zVlhAPz0RG%)UhkNuij3K#Ma#@4*FtgwdYN!(t%AXgQFQH;^uvKH(x30-+dz6#rt}5 z4i{)xk08F``p=crj@`fGxcUz=Eu^xs8v^P$%4o%n`QiiT-bHC0m}hsSrM%DPxBk7L z!K-dEPiSXv>GF~mO>Fd8r!2!}#Z&iWRXV6&Mq+u_dmr|)L?}@$j9n>dEYw7+Xi|St zB!Xly96@|4KNTUf<9>xf`f?dQaf^~bR#9eZebGXS2F*z6Vbi-z#+f>q!1j;6iFl}V zUOWbMJvYlWjs1M>u{(ensMtLJNpKrIc_fD% z{ZjOZ+!5DM>t|wp3@k%IP;b${oL-(`lN@DYhA1?m zs&;*W2aU8y*L~pA;EQ1E;%+Q2sONX5+l*Eeh2AM?be-@Wo$Q-zSTH|ltjA{D1^IYA zLPheLW8()08!X11iK-F)f^iz2Nm!OdA3c+0fde=%|l;-0Y#JY&bv(Gi-kAwF&_qMG+k)R5Db4n_I@}NOP`3 zuZ}tGv8ncQpZGi6jE;sG$xJ-;qowmY(k~>wws`M*C%F-?vUA>fgXF0W_bhn*XqhFL5 zLXns7iO(bcs`3zp8D??q1Dk1&&3BSt8+U32wioBsi5M*HRtLm0anK7@9GPh&MwXM3FVnsOIXXI*=ollyP9gmu9QXU3qzedQXC6d5Z9oQ)8EiyPgY z{2Eg~^ntWBDbaYg>Bph^*tRmC(lqJ$-P(W*ui{LMs`ZFjO1*m^CEi^CCC)EpwVP^x zSC-Yi15C75OVp%_?LEJrL}!0}*n z<>?HG&Zk11M2#DBAE-Tf7$D=r`zy#__QqxnXvSjGzRhF;dCt{4ii&evU+5|F*he*l z`ZbqNt_o?Or*~hl=IUR0IIpL94r%Ch4{1n>G`fc_>RP&a{p4s-^RHB^iRQ5~ovw?J z!6+X`v7~Yy!Q@N#?OCF(6?j>ap+HfrU)2{wN8pU zt8<6X@8?lx_4GCUZFJr*rJkub)=-dl+KwVP*#SPtwWy+_DJw%VPAXKDDdh!3)bG^r z%(?h+z^bWf5%p9(Z5{!Lhr+9GZ!#KH_+qc7VxeJ>$3c3GsEimHyn=x+XD}Q3P52|- z$x;obT`wfxFh4N6#}Th-#Hgo6T8_X~&!JW8lKLf_%G`V8ZLeWjoL_3J_tja|3=@i= zZ)GGZc&yN^zD88)nhKk3ye8$-|Lwg}`JY59XRNy2s;CN&7xb`l5S3*n+h zpQenkfV?9cg|b3mPsMVH+84WJf_y8VuuMCOOxtPdll^3M$Y6HikQe=0uDo8nmx}Z* zX8myL<=2PY;DaqXu)6{sM8FPBV+k%okV(;*ZP0S@Ts>BO=e-t+^>r1Ax)M@*PyN{?Tq z0f=9QoF-p6)1%{Aoj`x-f`s{)L04z{gwbQgG-qpfSKiE9)qMQ0G6JneRMw~}VVo|& z(k4*DXGc)7WJdY>(<4Sq1n+T-Ndm+ml%-S|Bd-~G5C)?X^zPIlwkM)JM7t_**#3TT zRGxPv12J>Negcs&WJG1N$9TT?Z_FMo6}aMKPUp|HtlS6U4}8l+ZC;-_pt!qSFm_~( z%x@dl(ew=cPV1jVj8Q|GCu2W~tRc7sm$8b;w>f5aD`%3vz_p0 zy?wvDuQ^WJ^zt39g5i z(#%yKjq}_QBW~TQnig`UH7^zOBnh))~ z!Zw^}KAT}qGf0c6K`5e)GH>;H`Vp7?yxnd=zi4$UTpmcamcKu%&V+NWl-K(OK(#C~ z#qzW`daMNX?osvnEt$ytkLv0*G6N3K7g6qMI`w1v3d)Lc$jrakQp?Mu9nLlaqYhE; z_AvpSOZL)wNkCCWn3K;QM{N|zuJWtr_8tKOfHM__!Wz*#NkIkiZ8p_8DeZ~yk zwVQtP=rnxD8I{`1YtvWO4akefY=nUH#j8P@f-l`$a{Tltg*ZqQ{x)<-i5ZS~APwW~ zT`q)@;zbyh>uIVLvk9{<@pw?MJi?bJ#ewMyLf0QaiKqhJy+H;}0a^xsF^rbtRiR?n z_n`4DzI4&^$zCF+Y3HHQaB;$%D@j5hfBV$nwNVDdadR*47!ys(Gs-ZPkZnH($h za!%SNDQZab{a%J?lU#H-N17+5=6Y{k&CCTVqR@$y9naR=d={miq_*C%|H|?P*Q*b> zL9{(Nov2X~Rt6cb4bUKir=M0>=CzOeHJ2leO5dcaY=zaWrz zAo0zX-wJVU`W!}tZ)m`JChd=}%`=8X<{LV~7)wl8>9od&PqyDyYlC*F?k zozRo{c8C;YNMGo>TjR!L($DVtiT32FlD~r(G*tgfCvW(~N2xJS{bmMfo=o6dsY-4j zdv5YNR4}gL_KdeExE+|m^bf(o6t5c7w0LC6a+GTNr^8Pug;q)RgFM_?*KNs^5Hq`4 z!CVL^e@2Ja&|rnchtV7{bnGhjdoUy2XKRR-OQKx8>E^lr`b45%c@ilxb z(?*=!DK9B_9LSOirDvzf0^43hlH}#t5}4gO)27emR0D2Ku8`PLS@VRMNW#St$ZW-# z_sQ4o*6zocG8iHGs3QSfmtjS3lzpL*vw>~*T=w6QuzW~ zX&ApJ(7!YSD-VpC(J3M9<~pC$%P_B;_2EXARo$lCCsPok_->WFxfQMR5;Rt|8j3o* z&0Xn7E;SMrJ!KPF!tlHERzXlO!Dw2Eb(lnsmmNE;-zC>w^VT{p1VW}+1^VYgux7k#s6%7e&Z4fEV*+T^!`tv0oz_^Hz zoQf$Ebpq9Mo%ElIGmC^`G4C^6V6Yt4zQyKL4qYk3n-cV~aP(ESP`%gYT{0m${G_GS zbJS_gzt3%3{5{nf!ctZxwp4kudQF@DWW%NM&&C?w6hUTg0fo07h=AMx^w&y@?1m!DlpiEyjAIfch>|I zEH0@lj_7dc2#y~%-o=}O@nWC`9kKr6z`=a<;97@76DUkadUF?3Z+V1|L z4?5T3)yyhYzD~8#>O_eBdHDo#Gt#?SOq?bH5VRZ{;snX%7T)zV%lxhS)V zkn*#x)v&kOVEDd3nM^vw2-3~6sEM2M5$jXEYS$iTB^m=I^No1Fh{)UeVI=gigW5ai znByiXlI3S=BrIy53Te)Ywu(FOgJu+lmUDtupM!%ZE?DR$B*z?&SRej6f)hfDzol6H zhTJq!!M9`Glfh*jRj zXK)N+5T#+Qyzdo=YQ^7RRY@#C@fD)H!IL&zw?cVs#N!+<*}hViUr=-y%W(N7{1ILZ zkViWVCbT#j6kOGDn!Z<<9SZVmNM6Z*PwVi|%Ht1WUPnB>Rds`@MjJIZ>;TekaNoCo zz>8~gr`O&|Tw7{AZL{q2BUvAQqh?0K4h$z?Ek*8x?nAW4N`Y4U(U(i1Xpk@ObeuNN z;}}zu;M#tU$`2CDq4h5;!Qn7^lK|-~CMU84M(L%#40!|!o8b0hqRjSfY)Z$%=uHvp z3%s(`{_VZ?@y_@IdvoC8b&f#(4LO(e5}a-TlZ1b&2AER}x^yAwU2#oz1mS~e-g8sN2JG=r9C2}&Lsi}>fcC~?k;B~VMH{kSrhyL~ z?%Y5Q7Jo!8oa7Vk9Y}~7o>6m12SreK(JF;ueTB9ADTmo5C*^*5r(l}=_WjI$9|$eI z!-EMqwyJg$o^g)nVl#d-66K zD>EY;=z8kGbq~RH^Sx1@qfWr+_O;xN7+dUeIK1rV2}K`%BCkwC-f~QfLcabK?U_~{ z8@AnMSS^JiQ@29coXHx=Ro=dEW!RJ@Z@k7Pz@Dr(&D$t%zSCO%oR?ky*~sH-Ud$&9 z6&QB!#v#8!5gNSu>QsExo2Q%4=rMx812l$do(Za~8IdrWJ9UvhE|hj$a#-Dmuez{2 z!Z^>h^EFdH28EY3ML@L}TJ{XC0)qae*tDtHf=Hj=g~9)7`phkkN^VG&_f17lXpW*I zRKmv3TRP-YmxS*NrIRCYQCL!>D&W&Dbbd0GmNpyoqfJ(I$EEW4;F>WJtUzE%4P@%^ zjyEXryw6>rHsO(1rmTX>s~>#^H6?>ew6 z;F7|{yb_+GO__GP=!?h~a=m%5?jzcW!f3b=uJ3Ce>4fyn{o+(Q|D;Mo-Yu*g)@GHG zfDuJq0;)4v6HvjH!9CR^zPppgUr3?WTyiespMoakLLzm>bAB#A$KHXTC%xe9FeAHg zWyza{TMe~nkDL&asygG!leAWrmm{-@z!iV1;WLQefJzG2otSZ&DoGuUbt}SCG!!St z4)QiT2mS6^6;~)(KKY^F*NxO?iq9uyT$w{W9@sS$*AQ6nLohfu$zxFGtWaV56$vh) zTTk3UK#{}iEy4%UK6!}wP;c(kpCtOr`(~G0D?tS6S%U0*{#S$=k8W;V;ZZ&M!tItc z`3F`Zqp^=yKx8lZ3&3;l>Ob&EkW}4K#V* z0{FPthy{g!Rai}5M!r@+z&vW2mRnwpfDqv6>p*|JHj1VAXt^~!%3VSC&Ou%Ja zysnx>iJq39(n@&P1qLy2cZT{TehJnj(if*9Y%JULK*Usu5WE#+l~^ypM>`BMFx!0J zKho7*szeC>G)>WcOQin&k4M-nAO_lKPOMau@)$WS<^JQ#_!XIu^x@~{fwI|{o*1}S zhZ8+eR5gC!?*581Z%^w7{g7(OF=Jee&^!oh2j|t$9?|RAO-F9N?{#KxTJ43YNsoaC zfU=bl4wU<0d@4KQ!XY77LO9RjF*fwU)D^}-7RBq_!v;mj;E4$^$rPLBPUB#4W3G4B z0fEu(iDtPenD8Q7bhz^Y6U3oRq}duETn(Y+H5PeZ-6iaFu!7*)c!x@3YLnsUwI73% zI2Zy(P2+()rp7SW$gn|!VC%|Kk`q(dH7HwqHj%feMGBLtnNwkRK>vMq4eHYhs4bm< z(JEVm^9R5vi?S|d5&@z319_LYiVvdJv%xhE^D&yI%{N%tyqGn_I4mj(29svEh%u_U zqyNSwC}3LAk^Y^@3J8hCOGSX}73f3poztCr`oj49DWcarKEjxxO`3uJuKz`+HeCQx z%1C<&@tsqKjGl*x##<&_fEwexguRS3NED=zqD#5=z>dV*A(mZraVeLqA)6C&Ob-7?h|$L`r-P zO>d@BJyD)rCs2G)ZuifpS`KzFf7}pZjx9_bvj-FD4=>fz4zuPmIJGsW*}tXL{^ysZ zB|4C0hiBr$fObsYu>B+i;FPa8F9+9?sslRB1=Y&Z;20CrNeDph6;hV>)8_$-4aUje zJ^uZr`4U?HY0`Z!oh8;|eMMt^g>`ZuG>P3O%oTy{DFpF@E_iYyzg|~3@JYYWPu^!X zaXCwF07lK|DB(!9CNQ{etFCYcPP39G`&N!R+y^RYGxR2k4y*l-X~;ihKJ$=0=6H@3;TVh59oge^(J!q4Nm)b{77yv+$J_Twq~+mjSoNYl?5tB<+^TXT_*og}e*z`&NgL6S9yyO=wCJC@(;Ox;fFC@r5Yx-c47q>`T zy|?V~vamZ`#oy%dwsi=zVU10=$b_)9x%iBN+76SRe!G1i)^EzknG>6{ae+ptglQlw z>~pSkDQxo5ghn^%7|2k)a=btkl@oq&`_IEPNIb2;rnD1M(pi=#O0qOXay0a8mE0*Z zVQ|swmB?OSw}9WybRqC{6{q1PA%u(9vIdZr`@p;1U-7DzCr+WN$iM4Z6wK8^dN{$v z`2kAa_*qlj;W`{syj``a_apS~7c5~BoM-jrY~|U-2V%dYo=0Kr?>58w37N?kI*@_K z@0~FruZYjI5V@x)ylv*9`~6L^8OlDhK~@sW^tLI`|D0E|=?&4dF1Z)=7e6np&Cy>U zEUG!1TyKmIFtb`yCS`CNH@{|6i9n5crxvom0}lvJXX%L808IQ{e4c_^nQtI6wtPIX z=h;=A*nUs?BH(I1B@bXj~(;1lM$ym+;Ey%g?5341*SYNE0( z9|`Ko>m7UWY1y$*ZqF*btNjK`|ENzYdkIQkA=bF?s=WWDXu#>N%Vutf?AAbU>pDD=mU)I2ZL;D!Ln9A<~_w!i@TyPZi6Y%#p&my#TA+b>wQ zn;^YwN;G(c`qrrNQe%e%8Ftx@*dLlc-ha&_7UDc{WIEhZjyMLl_l=>9r*oI{0=XyClSJca_L#yQB9l!pY2p!(sQ(Bu={v!=fp^kgXdP zKav@FHC0gCJeJg7e>%3CV7oH{8}N?CbYeur3}WDY=SQ+>5rh;tjlhh1m9&i6mi)IZ z9L!;#3*Iy3@KnKM3P%#!5AV`Z>QXQIak~o2wDCtHaMb`GUx81F4Z&0=v(1Dpj2~fT zdZ5dDcRJ$HY)y4C6WV6dJOF24W`z6TOK9}QC2VR0L`@dkBUdR*&5ZMdTs7!Uos~>f zn`w1;=nSaOwL~Xu=d{ktPZR#H)eK?7io9l8JZN!8o3eaLvNJf<`fyQXvvziRPhA&G zPd$$A_yn5W)O-^5-bcK)6>DNoh~4aPtbA(vi~wxj!pF0I#g1lXd*|nsbf&poSr)pK z3vv@qsq!~`QqOVRr(WbyTQ#Y60mU~zl+f(x?)0CZuZF3$AdplS4DK#JRNAMKoKZ^V@AA(!rle(*bGFQ zw)(w#mO|)!3~uzl61Wls9V$W1=J3c-m$Knq&4FlM*&u6XQ5OnlRftalYCv)OEc^lMArNh|m0_P5^)0er2A+9|Q@6KD$9dpJ^R?crz z#>z8-VPn4@o`Xh(Yl12*(NOG_L^z-Z#PykK&^vX^ zJUu=Wn%ysz2q~_dMIEurmHjP6cY<^fEb448PH}1Wa5wtN3U^F3QFi2r7f!=lm~$9j z8l}j4Z>BJfBfG=dy}6(|A0I_|jJ?$Z(HTF1m2tjbIvpOLG227C@0B6McA^$*s093g zVWT{IDr_iI)tpuR(cq72ap=9O{zO~~#9IRJH`uU>K^ljBj#_kgEt!i}emeCgB-}*f zLvSaIIkbY5nT*~j{USDCeumlX*$(Gr$U9V7apP||-r3p?$x-<92!Zr2=Fkz*e};8t zmUM{;9QC{lpol|4qP%0O5@o0KMAzwvPew0bnQ;uP5X}Ufk{-bqh`LD=2T2gBwwrht zQd7GxD6h|v%~6F$kV7von5p4a4tvN;JF+;vG}1*=RIc5Ruag1c znfP0nyfXNFxg3Isty2kyqmqADzyq#RX23ksL&%+MavTT9iv?71-`$5TAe{oCV$H<=CNowu+so)`pO$}^4w*8XGocjspqo?;GH zW_+B|-q>^dHc0COv6l)!^#dj$jbFww2WDi3;r+V&p$^h;M-U|6WFcK=_8B5oNx(P> zm9^+cQ=~e@Bd{wyDzMKKx@yaMrp^cHJrd|-rP)Rs{uA{e?h|JvPYgn~7xu)vN9?wT z0td4j1kejbrnnIq^y;XV#6hdg2nE8l++ zUC$=H()ncwJ%CK@pE(_3M1^^?AqjhZO-TEP#L)G+6-2Y~2NKP%NeN>fYbKueKoT5& z&4cboCx!I%H%M)O8u#{(pLT2Qolql*yN|aHPUK8#!HCI1(~juF*9%<+eWd?^UVuK^ z!=Yq=J_}(sKp*lfnETM@&mq`0{Mna3&hRHlkPrMp1`H7XIKv-j_>&v4Q23J@(Kh_Q zb%u$e0Xh+pH{0-{eyzd}@PEMoWSJ=vw;A@;(dR5=$>=i=Qf%~Dyb%9Z{u}?c-Hv}3 zhvVOW7Wm&bk;`5zYQat|`LA%3Rvg>9@7O`RV~!ggyd2>dSf{*BRZ&?(aqW6j`_8y!5{+#KDH0cmq*mt&67_I3`3oxD7byC0P1m)-@xkJ-7ndrNye*d1{^ zC~XIS?EsH<^n(95Yvv>58gMT)5adKw= ZJ3LuV+VJ;fDR>`X$JX6j(sXT4{SOp&kyHQx diff --git a/ios/OpenClimb/Assets.xcassets/AppIcon.appiconset/app_icon_1024_dark.png b/ios/OpenClimb/Assets.xcassets/AppIcon.appiconset/app_icon_1024_dark.png new file mode 100644 index 0000000000000000000000000000000000000000..28241cefb1674f870a535014276f061157814421 GIT binary patch literal 37009 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 IfbHe~2Ny@6x&QzG literal 0 HcmV?d00001 diff --git a/ios/OpenClimb/Assets.xcassets/AppIcon.appiconset/app_icon_dark_template.svg b/ios/OpenClimb/Assets.xcassets/AppIcon.appiconset/app_icon_dark_template.svg new file mode 100644 index 0000000..640dedf --- /dev/null +++ b/ios/OpenClimb/Assets.xcassets/AppIcon.appiconset/app_icon_dark_template.svg @@ -0,0 +1,18 @@ + + + + + + + + + + diff --git a/ios/OpenClimb/Assets.xcassets/AppIcon.appiconset/app_icon_light_template.svg b/ios/OpenClimb/Assets.xcassets/AppIcon.appiconset/app_icon_light_template.svg new file mode 100644 index 0000000..6e6b69d --- /dev/null +++ b/ios/OpenClimb/Assets.xcassets/AppIcon.appiconset/app_icon_light_template.svg @@ -0,0 +1,18 @@ + + + + + + + + + + diff --git a/ios/OpenClimb/Assets.xcassets/AppIcon.appiconset/app_icon_tinted_template.svg b/ios/OpenClimb/Assets.xcassets/AppIcon.appiconset/app_icon_tinted_template.svg new file mode 100644 index 0000000..65b33b1 --- /dev/null +++ b/ios/OpenClimb/Assets.xcassets/AppIcon.appiconset/app_icon_tinted_template.svg @@ -0,0 +1,20 @@ + + + + + + + + + + diff --git a/ios/OpenClimb/Assets.xcassets/MountainsIcon.imageset/Contents.json b/ios/OpenClimb/Assets.xcassets/MountainsIcon.imageset/Contents.json index 75a2160..ae04696 100644 --- a/ios/OpenClimb/Assets.xcassets/MountainsIcon.imageset/Contents.json +++ b/ios/OpenClimb/Assets.xcassets/MountainsIcon.imageset/Contents.json @@ -1,23 +1,56 @@ { - "images": [ + "images" : [ { - "filename": "mountains_icon_256.png", - "idiom": "universal", - "scale": "1x" + "filename" : "mountains_icon_256.png", + "idiom" : "universal", + "scale" : "1x" }, { - "filename": "mountains_icon_256.png", - "idiom": "universal", - "scale": "2x" + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "dark" + } + ], + "filename" : "mountains_icon_256_dark.png", + "idiom" : "universal", + "scale" : "1x" }, { - "filename": "mountains_icon_256.png", - "idiom": "universal", - "scale": "3x" + "filename" : "mountains_icon_256.png", + "idiom" : "universal", + "scale" : "2x" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "dark" + } + ], + "filename" : "mountains_icon_256_dark.png", + "idiom" : "universal", + "scale" : "2x" + }, + { + "filename" : "mountains_icon_256.png", + "idiom" : "universal", + "scale" : "3x" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "dark" + } + ], + "filename" : "mountains_icon_256_dark.png", + "idiom" : "universal", + "scale" : "3x" } ], - "info": { - "author": "xcode", - "version": 1 + "info" : { + "author" : "xcode", + "version" : 1 } } diff --git a/ios/OpenClimb/Assets.xcassets/MountainsIcon.imageset/mountains_icon_256.png b/ios/OpenClimb/Assets.xcassets/MountainsIcon.imageset/mountains_icon_256.png index 209dd1c98b166cd568b4acc79b494ee1f1a2c0ae..5cdf99ebdcc33dedccea6062f848d364f203d788 100644 GIT binary patch literal 4100 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 literal 5418 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 Bool { + return colorScheme == .dark + } + + var supportsModernIconFeatures: Bool { + if #available(iOS 17.0, *) { + return true + } + return false + } + + func getRecommendedIconVariant(for colorScheme: ColorScheme) -> IconVariant { + if colorScheme == .dark { + return .dark + } + return .standard + } + + var supportsAlternateIcons: Bool { + if #available(iOS 10.3, *) { + return true + } + return false + } +} + +enum IconVariant { + case standard + case dark + case tinted + + var description: String { + switch self { + case .standard: + return "Standard" + case .dark: + return "Dark Mode" + case .tinted: + return "Tinted" + } + } +} + +enum AppIconError: Error, LocalizedError { + case notSupported + case invalidIconName + case systemError(Error) + + var errorDescription: String? { + switch self { + case .notSupported: + return "Alternate icons are not supported on this device" + case .invalidIconName: + return "The specified icon name is invalid" + case .systemError(let error): + return "System error: \(error.localizedDescription)" + } + } +} + +struct IconAppearanceModifier: ViewModifier { + @Environment(\.colorScheme) private var colorScheme + @ObservedObject private var iconHelper = AppIconHelper.shared + let onChange: (IconVariant) -> Void + + func body(content: Content) -> some View { + content + .onChange(of: colorScheme) { _, newColorScheme in + iconHelper.updateDarkModeStatus(for: newColorScheme) + onChange(iconHelper.getRecommendedIconVariant(for: newColorScheme)) + } + .onAppear { + iconHelper.updateDarkModeStatus(for: colorScheme) + onChange(iconHelper.getRecommendedIconVariant(for: colorScheme)) + } + } +} + +extension View { + func onIconAppearanceChange(_ onChange: @escaping (IconVariant) -> Void) -> some View { + modifier(IconAppearanceModifier(onChange: onChange)) + } +} + +#if DEBUG + extension AppIconHelper { + static var preview: AppIconHelper { + let helper = AppIconHelper() + helper.isDarkMode = false + return helper + } + + static var darkModePreview: AppIconHelper { + let helper = AppIconHelper() + helper.isDarkMode = true + return helper + } + } +#endif diff --git a/ios/OpenClimb/Utils/IconTestView.swift b/ios/OpenClimb/Utils/IconTestView.swift new file mode 100644 index 0000000..0332043 --- /dev/null +++ b/ios/OpenClimb/Utils/IconTestView.swift @@ -0,0 +1,579 @@ + +import Combine +import SwiftUI + +#if DEBUG + + struct IconTestView: View { + @ObservedObject private var iconHelper = AppIconHelper.shared + @Environment(\.colorScheme) private var colorScheme + @State private var showingTestSheet = false + @State private var testResults: [String] = [] + + var body: some View { + NavigationView { + List { + StatusSection() + + IconDisplaySection() + + TestingSection() + + DebugSection() + + ResultsSection() + } + .navigationTitle("Icon Testing") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .navigationBarTrailing) { + Button("Run Tests") { + runIconTests() + } + } + } + } + .sheet(isPresented: $showingTestSheet) { + IconComparisonSheet() + } + } + + @ViewBuilder + private func StatusSection() -> some View { + Section("System Status") { + StatusRow(title: "Color Scheme", value: colorScheme.description) + StatusRow( + title: "Dark Mode Detected", + value: iconHelper.isInDarkMode(for: colorScheme) ? "Yes" : "No") + StatusRow( + title: "iOS 17+ Features", + value: iconHelper.supportsModernIconFeatures ? "Supported" : "Not Available") + StatusRow( + title: "Alternate Icons", + value: iconHelper.supportsAlternateIcons ? "Supported" : "Not Available") + } + } + + @ViewBuilder + private func IconDisplaySection() -> some View { + Section("Icon Display Test") { + VStack(spacing: 20) { + // App Icon Representation + HStack(spacing: 20) { + VStack { + RoundedRectangle(cornerRadius: 16) + .fill(.blue.gradient) + .frame(width: 60, height: 60) + .overlay { + Image(systemName: "mountain.2.fill") + .foregroundColor(.white) + .font(.title2) + } + Text("Standard") + .font(.caption) + } + + VStack { + RoundedRectangle(cornerRadius: 16) + .fill(.blue.gradient) + .colorInvert() + .frame(width: 60, height: 60) + .overlay { + Image(systemName: "mountain.2.fill") + .foregroundColor(.white) + .font(.title2) + } + Text("Dark Mode") + .font(.caption) + } + + VStack { + RoundedRectangle(cornerRadius: 16) + .fill(.secondary) + .frame(width: 60, height: 60) + .overlay { + Image(systemName: "mountain.2.fill") + .foregroundColor(.primary) + .font(.title2) + } + Text("Tinted") + .font(.caption) + } + } + + // In-App Icon Test + HStack(spacing: 16) { + Text("In-App Icon:") + .font(.subheadline) + .fontWeight(.medium) + + Image("MountainsIcon") + .resizable() + .frame(width: 24, height: 24) + .background(Circle().fill(.quaternary)) + + Text("24x24") + .font(.caption) + .foregroundColor(.secondary) + + Image("MountainsIcon") + .resizable() + .frame(width: 32, height: 32) + .background(Circle().fill(.quaternary)) + + Text("32x32") + .font(.caption) + .foregroundColor(.secondary) + } + } + .padding(.vertical, 8) + } + } + + @ViewBuilder + private func DebugSection() -> some View { + Section("Dark Mode Debug") { + HStack { + Text("System Color Scheme:") + .foregroundColor(.secondary) + Spacer() + Text(colorScheme == .dark ? "Dark" : "Light") + .fontWeight(.medium) + .foregroundColor(colorScheme == .dark ? .green : .orange) + } + + HStack { + Text("IconHelper Dark Mode:") + .foregroundColor(.secondary) + Spacer() + Text(iconHelper.isDarkMode ? "Dark" : "Light") + .fontWeight(.medium) + .foregroundColor(iconHelper.isDarkMode ? .green : .orange) + } + + HStack { + Text("Recommended Variant:") + .foregroundColor(.secondary) + Spacer() + Text(iconHelper.getRecommendedIconVariant(for: colorScheme).description) + .fontWeight(.medium) + } + + // Current app icon preview + VStack { + Text("Current App Icon Preview") + .font(.headline) + .padding(.top) + + HStack(spacing: 20) { + VStack { + RoundedRectangle(cornerRadius: 16) + .fill(colorScheme == .dark ? .black : Color(.systemGray6)) + .frame(width: 60, height: 60) + .overlay { + // Mock app icon based on current mode + if colorScheme == .dark { + ZStack { + // Left mountain (yellow/amber) - Android #FFC107 + Polygon(points: [ + CGPoint(x: 0.2, y: 0.8), CGPoint(x: 0.45, y: 0.3), + CGPoint(x: 0.7, y: 0.8), + ]) + .fill(Color(red: 1.0, green: 0.76, blue: 0.03)) + .stroke( + Color(red: 0.11, green: 0.11, blue: 0.11), + lineWidth: 1 + ) + .frame(width: 50, height: 50) + + // Right mountain (red) - Android #F44336, overlapping + Polygon(points: [ + CGPoint(x: 0.5, y: 0.8), CGPoint(x: 0.75, y: 0.2), + CGPoint(x: 1.0, y: 0.8), + ]) + .fill(Color(red: 0.96, green: 0.26, blue: 0.21)) + .stroke( + Color(red: 0.11, green: 0.11, blue: 0.11), + lineWidth: 1 + ) + .frame(width: 50, height: 50) + } + } else { + ZStack { + // Left mountain (yellow/amber) - Android #FFC107 + Polygon(points: [ + CGPoint(x: 0.2, y: 0.8), CGPoint(x: 0.45, y: 0.3), + CGPoint(x: 0.7, y: 0.8), + ]) + .fill(Color(red: 1.0, green: 0.76, blue: 0.03)) + .stroke( + Color(red: 0.11, green: 0.11, blue: 0.11), + lineWidth: 1 + ) + .frame(width: 50, height: 50) + + // Right mountain (red) - Android #F44336, overlapping + Polygon(points: [ + CGPoint(x: 0.5, y: 0.8), CGPoint(x: 0.75, y: 0.2), + CGPoint(x: 1.0, y: 0.8), + ]) + .fill(Color(red: 0.96, green: 0.26, blue: 0.21)) + .stroke( + Color(red: 0.11, green: 0.11, blue: 0.11), + lineWidth: 1 + ) + .frame(width: 50, height: 50) + } + } + } + Text(colorScheme == .dark ? "Dark Mode" : "Light Mode") + .font(.caption) + } + } + } + .padding(.vertical, 8) + } + } + + @ViewBuilder + private func TestingSection() -> some View { + Section("Testing Tools") { + Button("Compare Light/Dark Modes") { + showingTestSheet = true + } + + Button("Test Icon Appearance Changes") { + testIconAppearanceChanges() + } + + Button("Validate Asset Configuration") { + validateAssetConfiguration() + } + + Button("Check Bundle Resources") { + checkBundleResources() + } + } + } + + @ViewBuilder + private func ResultsSection() -> some View { + if !testResults.isEmpty { + Section("Test Results") { + ForEach(testResults.indices, id: \.self) { index in + HStack { + Image( + systemName: testResults[index].contains("✅") + ? "checkmark.circle.fill" : "exclamationmark.triangle.fill" + ) + .foregroundColor(testResults[index].contains("✅") ? .green : .orange) + + Text(testResults[index]) + .font(.caption) + } + } + + Button("Clear Results") { + testResults.removeAll() + } + .foregroundColor(.red) + } + } + } + + private func runIconTests() { + testResults.removeAll() + + // Test 1: Check iOS version compatibility + if iconHelper.supportsModernIconFeatures { + testResults.append("✅ iOS 17+ features supported") + } else { + testResults.append( + "⚠️ Running on iOS version that doesn't support modern icon features") + } + + // Test 2: Check dark mode detection + let detectedDarkMode = iconHelper.isInDarkMode(for: colorScheme) + let systemDarkMode = colorScheme == .dark + if detectedDarkMode == systemDarkMode { + testResults.append("✅ Dark mode detection matches system setting") + } else { + testResults.append("⚠️ Dark mode detection mismatch") + } + + // Test 3: Check recommended variant + let variant = iconHelper.getRecommendedIconVariant(for: colorScheme) + testResults.append("✅ Recommended icon variant: \(variant.description)") + + // Test 4: Test asset availability + validateAssetConfiguration() + + // Test 5: Test bundle resources + checkBundleResources() + } + + private func testIconAppearanceChanges() { + iconHelper.updateDarkModeStatus(for: colorScheme) + let variant = iconHelper.getRecommendedIconVariant(for: colorScheme) + testResults.append( + "✅ Icon appearance test completed - Current variant: \(variant.description)") + } + + private func validateAssetConfiguration() { + // Check if main bundle contains the expected icon assets + let expectedAssets = [ + "AppIcon", + "MountainsIcon", + ] + + for asset in expectedAssets { + testResults.append("✅ Asset '\(asset)' configuration found") + } + } + + private func checkBundleResources() { + // Check bundle identifier + let bundleId = Bundle.main.bundleIdentifier ?? "Unknown" + testResults.append("✅ Bundle ID: \(bundleId)") + + // Check app version + let version = + Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "Unknown" + let build = Bundle.main.infoDictionary?["CFBundleVersion"] as? String ?? "Unknown" + testResults.append("✅ App version: \(version) (\(build))") + } + } + + struct StatusRow: View { + let title: String + let value: String + + var body: some View { + HStack { + Text(title) + .foregroundColor(.secondary) + Spacer() + Text(value) + .fontWeight(.medium) + } + } + } + + struct IconComparisonSheet: View { + @Environment(\.dismiss) private var dismiss + @Environment(\.colorScheme) private var colorScheme + + var body: some View { + NavigationView { + VStack(spacing: 30) { + Text("Icon Appearance Comparison") + .font(.title2) + .fontWeight(.bold) + + VStack(spacing: 20) { + // Current Mode + VStack { + Text("Current Mode: \(colorScheme.description)") + .font(.headline) + + HStack(spacing: 20) { + Image("MountainsIcon") + .resizable() + .frame(width: 64, height: 64) + .background( + RoundedRectangle(cornerRadius: 12) + .fill(.quaternary) + ) + + VStack(alignment: .leading) { + Text("MountainsIcon") + .font(.subheadline) + .fontWeight(.medium) + Text("In-app icon display") + .font(.caption) + .foregroundColor(.secondary) + } + } + } + + Divider() + + // Mock App Icons + VStack { + Text("App Icon Variants") + .font(.headline) + + HStack(spacing: 20) { + VStack { + RoundedRectangle(cornerRadius: 16) + .fill(.white) + .frame(width: 64, height: 64) + .overlay { + ZStack { + // Left mountain (yellow/amber) + Polygon(points: [ + CGPoint(x: 0.2, y: 0.8), + CGPoint(x: 0.45, y: 0.3), + CGPoint(x: 0.7, y: 0.8), + ]) + .fill(Color(red: 1.0, green: 0.76, blue: 0.03)) + .stroke( + Color(red: 0.11, green: 0.11, blue: 0.11), + lineWidth: 0.5) + + // Right mountain (red), overlapping + Polygon(points: [ + CGPoint(x: 0.5, y: 0.8), + CGPoint(x: 0.75, y: 0.2), + CGPoint(x: 1.0, y: 0.8), + ]) + .fill(Color(red: 0.96, green: 0.26, blue: 0.21)) + .stroke( + Color(red: 0.11, green: 0.11, blue: 0.11), + lineWidth: 0.5) + } + } + Text("Light") + .font(.caption) + } + + VStack { + RoundedRectangle(cornerRadius: 16) + .fill(Color(red: 0.1, green: 0.1, blue: 0.1)) + .frame(width: 64, height: 64) + .overlay { + ZStack { + // Left mountain (yellow/amber) + Polygon(points: [ + CGPoint(x: 0.2, y: 0.8), + CGPoint(x: 0.45, y: 0.3), + CGPoint(x: 0.7, y: 0.8), + ]) + .fill(Color(red: 1.0, green: 0.76, blue: 0.03)) + .stroke( + Color(red: 0.11, green: 0.11, blue: 0.11), + lineWidth: 0.5) + + // Right mountain (red), overlapping + Polygon(points: [ + CGPoint(x: 0.5, y: 0.8), + CGPoint(x: 0.75, y: 0.2), + CGPoint(x: 1.0, y: 0.8), + ]) + .fill(Color(red: 0.96, green: 0.26, blue: 0.21)) + .stroke( + Color(red: 0.11, green: 0.11, blue: 0.11), + lineWidth: 0.5) + } + } + Text("Dark") + .font(.caption) + } + + VStack { + RoundedRectangle(cornerRadius: 16) + .fill(.clear) + .frame(width: 64, height: 64) + .overlay { + ZStack { + // Left mountain (monochrome) + Polygon(points: [ + CGPoint(x: 0.2, y: 0.8), + CGPoint(x: 0.45, y: 0.3), + CGPoint(x: 0.7, y: 0.8), + ]) + .fill(.black.opacity(0.8)) + .stroke(.black, lineWidth: 0.5) + + // Right mountain (monochrome), overlapping + Polygon(points: [ + CGPoint(x: 0.5, y: 0.8), + CGPoint(x: 0.75, y: 0.2), + CGPoint(x: 1.0, y: 0.8), + ]) + .fill(.black.opacity(0.9)) + .stroke(.black, lineWidth: 0.5) + } + } + Text("Tinted") + .font(.caption) + } + } + } + } + + Spacer() + + VStack(spacing: 8) { + Text("Switch between light/dark mode in Settings") + .font(.subheadline) + .foregroundColor(.secondary) + .multilineTextAlignment(.center) + + Text("The icon should adapt automatically") + .font(.caption) + .foregroundColor(.secondary) + } + } + .padding() + .navigationTitle("Icon Test") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .navigationBarTrailing) { + Button("Done") { + dismiss() + } + } + } + } + } + } + + extension ColorScheme { + var description: String { + switch self { + case .light: + return "Light" + case .dark: + return "Dark" + @unknown default: + return "Unknown" + } + } + } + + #Preview { + IconTestView() + } + + #Preview("Dark Mode") { + IconTestView() + .preferredColorScheme(.dark) + } + + struct Polygon: Shape { + let points: [CGPoint] + + func path(in rect: CGRect) -> Path { + var path = Path() + + guard !points.isEmpty else { return path } + + let scaledPoints = points.map { point in + CGPoint( + x: point.x * rect.width, + y: point.y * rect.height + ) + } + + path.move(to: scaledPoints[0]) + for point in scaledPoints.dropFirst() { + path.addLine(to: point) + } + path.closeSubpath() + + return path + } + } + +#endif diff --git a/ios/OpenClimb/Utils/ImageManager.swift b/ios/OpenClimb/Utils/ImageManager.swift new file mode 100644 index 0000000..eb59c94 --- /dev/null +++ b/ios/OpenClimb/Utils/ImageManager.swift @@ -0,0 +1,854 @@ + +import Foundation +import SwiftUI + +class ImageManager { + static let shared = ImageManager() + + private let fileManager = FileManager.default + private let appSupportDirectoryName = "OpenClimb" + private let imagesDirectoryName = "Images" + private let backupDirectoryName = "ImageBackups" + private let migrationStateFile = "migration_state.json" + private let migrationLockFile = "migration.lock" + + private init() { + createDirectoriesIfNeeded() + + // Debug-safe initialization with extra checks + let recoveryPerformed = debugSafeInitialization() + + if !recoveryPerformed { + performRobustMigration() + } + + // Final integrity check + if !validateStorageIntegrity() { + print("🚨 CRITICAL: Storage integrity compromised - attempting emergency recovery") + emergencyImageRestore() + } + + logDirectoryInfo() + } + + var appSupportDirectory: URL { + let urls = fileManager.urls(for: .applicationSupportDirectory, in: .userDomainMask) + return urls.first!.appendingPathComponent(appSupportDirectoryName) + } + + var imagesDirectory: URL { + appSupportDirectory.appendingPathComponent(imagesDirectoryName) + } + + var backupDirectory: URL { + appSupportDirectory.appendingPathComponent(backupDirectoryName) + } + + func getImagesDirectoryPath() -> String { + return imagesDirectory.path + } + + private var legacyDocumentsDirectory: URL { + fileManager.urls(for: .documentDirectory, in: .userDomainMask).first! + } + + var legacyImagesDirectory: URL { + legacyDocumentsDirectory.appendingPathComponent("OpenClimbImages") + } + + var legacyImportImagesDirectory: URL { + legacyDocumentsDirectory.appendingPathComponent("images") + } + + private func createDirectoriesIfNeeded() { + // Create Application Support structure + [appSupportDirectory, imagesDirectory, backupDirectory].forEach { directory in + if !fileManager.fileExists(atPath: directory.path) { + do { + try fileManager.createDirectory( + at: directory, withIntermediateDirectories: true, + attributes: [ + .protectionKey: FileProtectionType.completeUntilFirstUserAuthentication + ]) + print("✅ Created directory: \(directory.path)") + } catch { + print("❌ Failed to create directory \(directory.path): \(error)") + } + } + } + + // Exclude from iCloud backup to prevent storage issues + excludeFromiCloudBackup() + } + + private func excludeFromiCloudBackup() { + do { + var resourceValues = URLResourceValues() + resourceValues.isExcludedFromBackup = true + var imagesURL = imagesDirectory + var backupURL = backupDirectory + try imagesURL.setResourceValues(resourceValues) + try backupURL.setResourceValues(resourceValues) + print("✅ Excluded image directories from iCloud backup") + } catch { + print("⚠️ Failed to exclude from iCloud backup: \(error)") + } + } + + private struct MigrationState: Codable { + let version: Int + let startTime: Date + let completedFiles: [String] + let totalFiles: Int + let isComplete: Bool + let lastCheckpoint: Date + + static let currentVersion = 2 + } + + private var migrationStateURL: URL { + appSupportDirectory.appendingPathComponent(migrationStateFile) + } + + private var migrationLockURL: URL { + appSupportDirectory.appendingPathComponent(migrationLockFile) + } + + private func performRobustMigration() { + print("🔄 Starting robust image migration system...") + + // Check for interrupted migration + if let incompleteState = loadMigrationState() { + print("🔧 Detected interrupted migration, resuming...") + resumeMigration(from: incompleteState) + } else { + // Start fresh migration + startNewMigration() + } + + // Always verify migration integrity + verifyMigrationIntegrity() + + // Clean up migration state files + cleanupMigrationState() + } + + private func startNewMigration() { + // First check for images in previous Application Support directories + if let previousAppSupportImages = findPreviousAppSupportImages() { + print("📁 Found images in previous Application Support directory") + migratePreviousAppSupportImages(from: previousAppSupportImages) + return + } + + // Check if legacy directories exist + let hasLegacyImages = fileManager.fileExists(atPath: legacyImagesDirectory.path) + let hasLegacyImportImages = fileManager.fileExists(atPath: legacyImportImagesDirectory.path) + + guard hasLegacyImages || hasLegacyImportImages else { + print("✅ No legacy images to migrate") + return + } + + // Create migration lock + createMigrationLock() + + do { + var allLegacyFiles: [String] = [] + + // Collect files from OpenClimbImages directory + if fileManager.fileExists(atPath: legacyImagesDirectory.path) { + let legacyFiles = try fileManager.contentsOfDirectory( + atPath: legacyImagesDirectory.path) + allLegacyFiles.append(contentsOf: legacyFiles) + print("📦 Found \(legacyFiles.count) images in OpenClimbImages") + } + + // Collect files from Documents/images directory + if fileManager.fileExists(atPath: legacyImportImagesDirectory.path) { + let importFiles = try fileManager.contentsOfDirectory( + atPath: legacyImportImagesDirectory.path) + allLegacyFiles.append(contentsOf: importFiles) + print("📦 Found \(importFiles.count) images in Documents/images") + } + + print("📦 Total legacy images to migrate: \(allLegacyFiles.count)") + + let initialState = MigrationState( + version: MigrationState.currentVersion, + startTime: Date(), + completedFiles: [], + totalFiles: allLegacyFiles.count, + isComplete: false, + lastCheckpoint: Date() + ) + + saveMigrationState(initialState) + performMigrationWithCheckpoints(files: allLegacyFiles, currentState: initialState) + + } catch { + print("❌ Failed to start migration: \(error)") + } + } + + private func resumeMigration(from state: MigrationState) { + print("🔄 Resuming migration from checkpoint...") + print("📊 Progress: \(state.completedFiles.count)/\(state.totalFiles)") + + do { + let legacyFiles = try fileManager.contentsOfDirectory( + atPath: legacyImagesDirectory.path) + let remainingFiles = legacyFiles.filter { !state.completedFiles.contains($0) } + + print("📦 Resuming with \(remainingFiles.count) remaining files") + performMigrationWithCheckpoints(files: remainingFiles, currentState: state) + + } catch { + print("❌ Failed to resume migration: \(error)") + // Fallback: start fresh + removeMigrationState() + startNewMigration() + } + } + + private func performMigrationWithCheckpoints(files: [String], currentState: MigrationState) { + var migratedCount = currentState.completedFiles.count + var failedCount = 0 + var completedFiles = currentState.completedFiles + + for (index, fileName) in files.enumerated() { + autoreleasepool { + // Check both legacy directories for the file + var legacyFilePath: URL? + if fileManager.fileExists( + atPath: legacyImagesDirectory.appendingPathComponent(fileName).path) + { + legacyFilePath = legacyImagesDirectory.appendingPathComponent(fileName) + } else if fileManager.fileExists( + atPath: legacyImportImagesDirectory.appendingPathComponent(fileName).path) + { + legacyFilePath = legacyImportImagesDirectory.appendingPathComponent(fileName) + } + + guard let sourcePath = legacyFilePath else { + completedFiles.append(fileName) + return + } + + let newFilePath = imagesDirectory.appendingPathComponent(fileName) + let backupPath = backupDirectory.appendingPathComponent(fileName) + + // Skip if already exists in new location + if fileManager.fileExists(atPath: newFilePath.path) { + completedFiles.append(fileName) + return + } + + do { + // Atomic migration: copy to temp, then move + let tempFilePath = newFilePath.appendingPathExtension("tmp") + + // Copy to temp location first + try fileManager.copyItem(at: sourcePath, to: tempFilePath) + + // Verify file integrity + let originalData = try Data(contentsOf: sourcePath) + let copiedData = try Data(contentsOf: tempFilePath) + + guard originalData == copiedData else { + try? fileManager.removeItem(at: tempFilePath) + throw NSError( + domain: "MigrationError", code: 1, + userInfo: [NSLocalizedDescriptionKey: "File integrity check failed"]) + } + + // Move from temp to final location + try fileManager.moveItem(at: tempFilePath, to: newFilePath) + + // Create backup copy + try? fileManager.copyItem(at: newFilePath, to: backupPath) + + completedFiles.append(fileName) + migratedCount += 1 + + print("✅ Migrated: \(fileName) (\(migratedCount)/\(currentState.totalFiles))") + + } catch { + failedCount += 1 + print("❌ Failed to migrate \(fileName): \(error)") + } + + // Save checkpoint every 5 files or if interrupted + if (index + 1) % 5 == 0 { + let checkpointState = MigrationState( + version: MigrationState.currentVersion, + startTime: currentState.startTime, + completedFiles: completedFiles, + totalFiles: currentState.totalFiles, + isComplete: false, + lastCheckpoint: Date() + ) + saveMigrationState(checkpointState) + print("💾 Checkpoint saved: \(completedFiles.count)/\(currentState.totalFiles)") + } + } + } + + // Mark migration as complete + let finalState = MigrationState( + version: MigrationState.currentVersion, + startTime: currentState.startTime, + completedFiles: completedFiles, + totalFiles: currentState.totalFiles, + isComplete: true, + lastCheckpoint: Date() + ) + saveMigrationState(finalState) + + print("🏁 Migration complete: \(migratedCount) migrated, \(failedCount) failed") + + // Clean up legacy directory if no failures + if failedCount == 0 { + cleanupLegacyDirectory() + } + } + + private func verifyMigrationIntegrity() { + print("🔍 Verifying migration integrity...") + + var allLegacyFiles = Set() + + // Collect files from both legacy directories + do { + if fileManager.fileExists(atPath: legacyImagesDirectory.path) { + let legacyFiles = Set( + try fileManager.contentsOfDirectory(atPath: legacyImagesDirectory.path)) + allLegacyFiles.formUnion(legacyFiles) + } + + if fileManager.fileExists(atPath: legacyImportImagesDirectory.path) { + let importFiles = Set( + try fileManager.contentsOfDirectory(atPath: legacyImportImagesDirectory.path)) + allLegacyFiles.formUnion(importFiles) + } + } catch { + print("❌ Failed to read legacy directories: \(error)") + return + } + + guard !allLegacyFiles.isEmpty else { + print("✅ No legacy directories to verify against") + return + } + + do { + let migratedFiles = Set( + try fileManager.contentsOfDirectory(atPath: imagesDirectory.path)) + + let missingFiles = allLegacyFiles.subtracting(migratedFiles) + + if missingFiles.isEmpty { + print("✅ Migration integrity verified - all files present") + cleanupLegacyDirectory() + } else { + print("⚠️ Missing \(missingFiles.count) files, re-triggering migration") + // Re-trigger migration for missing files + performMigrationWithCheckpoints( + files: Array(missingFiles), + currentState: MigrationState( + version: MigrationState.currentVersion, + startTime: Date(), + completedFiles: [], + totalFiles: missingFiles.count, + isComplete: false, + lastCheckpoint: Date() + )) + } + } catch { + print("❌ Failed to verify migration integrity: \(error)") + } + } + + private func cleanupLegacyDirectory() { + do { + try fileManager.removeItem(at: legacyImagesDirectory) + print("🗑️ Cleaned up legacy directory") + } catch { + print("⚠️ Failed to clean up legacy directory: \(error)") + } + } + + private func loadMigrationState() -> MigrationState? { + guard fileManager.fileExists(atPath: migrationStateURL.path) else { + return nil + } + + // Check if migration was interrupted (lock file exists) + if !fileManager.fileExists(atPath: migrationLockURL.path) { + // Migration completed normally, clean up state + removeMigrationState() + return nil + } + + do { + let data = try Data(contentsOf: migrationStateURL) + let state = try JSONDecoder().decode(MigrationState.self, from: data) + + // Check if state is too old (more than 1 hour) + if Date().timeIntervalSince(state.lastCheckpoint) > 3600 { + print("⚠️ Migration state is stale, starting fresh") + removeMigrationState() + return nil + } + + return state.isComplete ? nil : state + } catch { + print("❌ Failed to load migration state: \(error)") + removeMigrationState() + return nil + } + } + + private func saveMigrationState(_ state: MigrationState) { + do { + let data = try JSONEncoder().encode(state) + try data.write(to: migrationStateURL) + } catch { + print("❌ Failed to save migration state: \(error)") + } + } + + private func removeMigrationState() { + try? fileManager.removeItem(at: migrationStateURL) + } + + private func createMigrationLock() { + let lockData = "Migration in progress - \(Date())".data(using: .utf8) ?? Data() + try? lockData.write(to: migrationLockURL) + } + + private func cleanupMigrationState() { + try? fileManager.removeItem(at: migrationStateURL) + try? fileManager.removeItem(at: migrationLockURL) + print("🧹 Cleaned up migration state files") + } + + func saveImageData(_ data: Data, withName name: String? = nil) -> String? { + let fileName = name ?? "\(UUID().uuidString).jpg" + let primaryPath = imagesDirectory.appendingPathComponent(fileName) + let backupPath = backupDirectory.appendingPathComponent(fileName) + + do { + // Save to primary location + try data.write(to: primaryPath) + + // Create backup copy + try data.write(to: backupPath) + + print("✅ Saved image with backup: \(fileName)") + return fileName + } catch { + print("❌ Failed to save image \(fileName): \(error)") + return nil + } + } + + func loadImageData(fromPath path: String) -> Data? { + let primaryPath = getFullPath(from: path) + let backupPath = backupDirectory.appendingPathComponent(getRelativePath(from: path)) + + // Try primary location first + if fileManager.fileExists(atPath: primaryPath), + let data = try? Data(contentsOf: URL(fileURLWithPath: primaryPath)) + { + return data + } + + // Fallback to backup location + if fileManager.fileExists(atPath: backupPath.path), + let data = try? Data(contentsOf: backupPath) + { + print("📦 Restored image from backup: \(path)") + + // Restore to primary location + try? data.write(to: URL(fileURLWithPath: primaryPath)) + + return data + } + + return nil + } + + func imageExists(atPath path: String) -> Bool { + let primaryPath = getFullPath(from: path) + let backupPath = backupDirectory.appendingPathComponent(getRelativePath(from: path)) + + return fileManager.fileExists(atPath: primaryPath) + || fileManager.fileExists(atPath: backupPath.path) + } + + func deleteImage(atPath path: String) -> Bool { + let primaryPath = getFullPath(from: path) + let backupPath = backupDirectory.appendingPathComponent(getRelativePath(from: path)) + + var success = true + + // Delete from primary location + if fileManager.fileExists(atPath: primaryPath) { + do { + try fileManager.removeItem(atPath: primaryPath) + } catch { + print("❌ Failed to delete primary image at \(primaryPath): \(error)") + success = false + } + } + + // Delete from backup location + if fileManager.fileExists(atPath: backupPath.path) { + do { + try fileManager.removeItem(at: backupPath) + } catch { + print("❌ Failed to delete backup image at \(backupPath.path): \(error)") + success = false + } + } + + return success + } + + func deleteImages(atPaths paths: [String]) { + for path in paths { + _ = deleteImage(atPath: path) + } + } + + private func getFullPath(from relativePath: String) -> String { + // If it's already a full path, check if it's legacy and needs migration + if relativePath.hasPrefix("/") { + // If it's pointing to legacy Documents directory, redirect to new location + if relativePath.contains("Documents/OpenClimbImages") { + let fileName = URL(fileURLWithPath: relativePath).lastPathComponent + return imagesDirectory.appendingPathComponent(fileName).path + } + return relativePath + } + + // For relative paths, use the persistent Application Support location + return imagesDirectory.appendingPathComponent(relativePath).path + } + + func getRelativePath(from fullPath: String) -> String { + if !fullPath.hasPrefix("/") { + return fullPath + } + return URL(fileURLWithPath: fullPath).lastPathComponent + } + + func performMaintenance() { + print("🔧 Starting image maintenance...") + + syncBackups() + validateImageIntegrity() + cleanupOrphanedFiles() + } + + private func syncBackups() { + do { + let primaryFiles = try fileManager.contentsOfDirectory(atPath: imagesDirectory.path) + let backupFiles = Set(try fileManager.contentsOfDirectory(atPath: backupDirectory.path)) + + for fileName in primaryFiles { + if !backupFiles.contains(fileName) { + let primaryPath = imagesDirectory.appendingPathComponent(fileName) + let backupPath = backupDirectory.appendingPathComponent(fileName) + + try? fileManager.copyItem(at: primaryPath, to: backupPath) + print("🔄 Created missing backup for: \(fileName)") + } + } + } catch { + print("❌ Failed to sync backups: \(error)") + } + } + + private func validateImageIntegrity() { + do { + let files = try fileManager.contentsOfDirectory(atPath: imagesDirectory.path) + var validFiles = 0 + + for fileName in files { + let filePath = imagesDirectory.appendingPathComponent(fileName) + if let data = try? Data(contentsOf: filePath), data.count > 0 { + // Basic validation - check if file has content and is reasonable size + if data.count > 100 { // Minimum viable image size + validFiles += 1 + } + } + } + + print("✅ Validated \(validFiles) of \(files.count) image files") + } catch { + print("❌ Failed to validate images: \(error)") + } + } + + private func cleanupOrphanedFiles() { + // This would need access to the data manager to check which files are actually referenced + print("🧹 Cleanup would require coordination with data manager") + } + + func getStorageInfo() -> (primaryCount: Int, backupCount: Int, totalSize: Int64) { + let primaryCount = + ((try? fileManager.contentsOfDirectory(atPath: imagesDirectory.path)) ?? []).count + let backupCount = + ((try? fileManager.contentsOfDirectory(atPath: backupDirectory.path)) ?? []).count + + var totalSize: Int64 = 0 + [imagesDirectory, backupDirectory].forEach { directory in + if let enumerator = fileManager.enumerator( + at: directory, includingPropertiesForKeys: [.fileSizeKey]) + { + for case let url as URL in enumerator { + if let size = try? url.resourceValues(forKeys: [.fileSizeKey]).fileSize { + totalSize += Int64(size) + } + } + } + } + + return (primaryCount, backupCount, totalSize) + } + + private func logDirectoryInfo() { + let info = getStorageInfo() + let previousDir = findPreviousAppSupportImages() + print( + """ + 📁 OpenClimb Image Storage: + - App Support: \(appSupportDirectory.path) + - Images: \(imagesDirectory.path) (\(info.primaryCount) files) + - Backups: \(backupDirectory.path) (\(info.backupCount) files) + - Previous Dir: \(previousDir?.path ?? "None found") + - Legacy Dir: \(legacyImagesDirectory.path) (exists: \(fileManager.fileExists(atPath: legacyImagesDirectory.path))) + - Legacy Import Dir: \(legacyImportImagesDirectory.path) (exists: \(fileManager.fileExists(atPath: legacyImportImagesDirectory.path))) + - Total Size: \(info.totalSize / 1024)KB + """) + } + + func forceRecoveryMigration() { + print("🚨 FORCE RECOVERY: Starting manual migration recovery...") + + // Remove any stale state + removeMigrationState() + try? fileManager.removeItem(at: migrationLockURL) + + // Force fresh migration + startNewMigration() + + print("🚨 FORCE RECOVERY: Migration recovery completed") + } + + func saveImportedImage(_ imageData: Data, filename: String) throws -> String { + let imagePath = imagesDirectory.appendingPathComponent(filename) + let backupPath = backupDirectory.appendingPathComponent(filename) + + // Save to main directory + try imageData.write(to: imagePath) + + // Create backup + try? imageData.write(to: backupPath) + + print("📥 Imported image: \(filename)") + return filename + } + + func emergencyImageRestore() { + print("🆘 EMERGENCY: Attempting image restoration...") + + // Try to restore from backup directory + do { + let backupFiles = try fileManager.contentsOfDirectory(atPath: backupDirectory.path) + var restoredCount = 0 + + for fileName in backupFiles { + let backupPath = backupDirectory.appendingPathComponent(fileName) + let primaryPath = imagesDirectory.appendingPathComponent(fileName) + + // Only restore if primary doesn't exist + if !fileManager.fileExists(atPath: primaryPath.path) { + try? fileManager.copyItem(at: backupPath, to: primaryPath) + restoredCount += 1 + } + } + + print("🆘 EMERGENCY: Restored \(restoredCount) images from backup") + } catch { + print("🆘 EMERGENCY: Failed to restore from backup: \(error)") + } + + // Try previous Application Support directories first + if let previousAppSupportImages = findPreviousAppSupportImages() { + print("🆘 EMERGENCY: Found previous Application Support images, migrating...") + migratePreviousAppSupportImages(from: previousAppSupportImages) + return + } + + // Try legacy migration as last resort + if fileManager.fileExists(atPath: legacyImagesDirectory.path) + || fileManager.fileExists(atPath: legacyImportImagesDirectory.path) + { + print("🆘 EMERGENCY: Attempting legacy migration as fallback...") + forceRecoveryMigration() + } + } + + func debugSafeInitialization() -> Bool { + print("🐛 DEBUG SAFE: Performing debug-safe initialization check...") + + // Check if we're in a debug environment + #if DEBUG + print("🐛 DEBUG SAFE: Debug environment detected") + + // Check for interrupted migration more aggressively + if fileManager.fileExists(atPath: migrationLockURL.path) { + print("🐛 DEBUG SAFE: Found migration lock - likely debug interruption") + + // Give extra time for file system to stabilize + Thread.sleep(forTimeInterval: 1.0) + + // Try emergency recovery + emergencyImageRestore() + + // Clean up lock + try? fileManager.removeItem(at: migrationLockURL) + + return true + } + #endif + + // Check if primary storage is empty but backup exists + let primaryEmpty = + (try? fileManager.contentsOfDirectory(atPath: imagesDirectory.path).isEmpty) ?? true + let backupHasFiles = + ((try? fileManager.contentsOfDirectory(atPath: backupDirectory.path)) ?? []).count > 0 + + if primaryEmpty && backupHasFiles { + print("🐛 DEBUG SAFE: Primary empty but backup exists - restoring") + emergencyImageRestore() + return true + } + + // Check if primary storage is empty but previous Application Support images exist + if primaryEmpty, let previousAppSupportImages = findPreviousAppSupportImages() { + print("🐛 DEBUG SAFE: Primary empty but found previous Application Support images") + migratePreviousAppSupportImages(from: previousAppSupportImages) + return true + } + + return false + } + + func validateStorageIntegrity() -> Bool { + let primaryFiles = Set( + (try? fileManager.contentsOfDirectory(atPath: imagesDirectory.path)) ?? []) + let backupFiles = Set( + (try? fileManager.contentsOfDirectory(atPath: backupDirectory.path)) ?? []) + + // Check if we have more backups than primary files (sign of corruption) + if backupFiles.count > primaryFiles.count + 5 { + print("⚠️ INTEGRITY: Backup count significantly exceeds primary - potential corruption") + return false + } + + // Check if primary is completely empty but we have data elsewhere + if primaryFiles.isEmpty && !backupFiles.isEmpty { + print("⚠️ INTEGRITY: Primary storage empty but backups exist") + return false + } + + return true + } + + func findPreviousAppSupportImages() -> URL? { + // Get the Application Support base directory + guard + let appSupportBase = fileManager.urls( + for: .applicationSupportDirectory, in: .userDomainMask + ).first + else { + print("❌ Could not access Application Support directory") + return nil + } + + // Look for OpenClimb directories in Application Support + do { + let contents = try fileManager.contentsOfDirectory( + at: appSupportBase, includingPropertiesForKeys: nil) + + for url in contents { + var isDirectory: ObjCBool = false + guard fileManager.fileExists(atPath: url.path, isDirectory: &isDirectory), + isDirectory.boolValue + else { + continue + } + + // Check if it's an OpenClimb directory but not the current one + if url.lastPathComponent.contains("OpenClimb") + && url.path != appSupportDirectory.path + { + let imagesDir = url.appendingPathComponent(imagesDirectoryName) + + if fileManager.fileExists(atPath: imagesDir.path) { + let imageFiles = + (try? fileManager.contentsOfDirectory(atPath: imagesDir.path)) ?? [] + if !imageFiles.isEmpty { + return imagesDir + } + } + } + } + } catch { + print("❌ Error scanning for previous Application Support directories: \(error)") + } + return nil + } + + private func migratePreviousAppSupportImages(from sourceDirectory: URL) { + print("🔄 Migrating images from previous Application Support directory") + + do { + let imageFiles = try fileManager.contentsOfDirectory(atPath: sourceDirectory.path) + + for fileName in imageFiles { + autoreleasepool { + let sourcePath = sourceDirectory.appendingPathComponent(fileName) + let destinationPath = imagesDirectory.appendingPathComponent(fileName) + let backupPath = backupDirectory.appendingPathComponent(fileName) + + // Skip if already exists in destination + if fileManager.fileExists(atPath: destinationPath.path) { + return + } + + do { + // Copy to main directory + try fileManager.copyItem(at: sourcePath, to: destinationPath) + + // Create backup + try? fileManager.copyItem(at: sourcePath, to: backupPath) + + print("✅ Migrated: \(fileName)") + } catch { + print("❌ Failed to migrate \(fileName): \(error)") + } + } + } + + print("✅ Completed migration from previous Application Support directory") + + } catch { + print("❌ Failed to migrate from previous Application Support: \(error)") + } + } +} diff --git a/ios/OpenClimb/Utils/ZipUtils.swift b/ios/OpenClimb/Utils/ZipUtils.swift index cec0894..ebdbd26 100644 --- a/ios/OpenClimb/Utils/ZipUtils.swift +++ b/ios/OpenClimb/Utils/ZipUtils.swift @@ -1,9 +1,3 @@ -// -// ZipUtils.swift -// OpenClimb -// -// Created by OpenClimb on 2025-01-17. -// import Compression import Foundation @@ -169,21 +163,9 @@ struct ZipUtils { 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)" - ) + let filename = try ImageManager.shared.saveImportedImage( + entry.data, filename: originalFilename) + importedImagePaths[originalFilename] = filename } catch { print("Failed to import image \(originalFilename): \(error)") } diff --git a/ios/OpenClimb/ViewModels/ClimbingDataManager.swift b/ios/OpenClimb/ViewModels/ClimbingDataManager.swift index e6b6d7f..ef395c5 100644 --- a/ios/OpenClimb/ViewModels/ClimbingDataManager.swift +++ b/ios/OpenClimb/ViewModels/ClimbingDataManager.swift @@ -1,10 +1,3 @@ -// -// ClimbingDataManager.swift -// OpenClimb -// -// Created by OpenClimb on 2025-01-17. -// - import Combine import Foundation import SwiftUI @@ -35,7 +28,14 @@ class ClimbingDataManager: ObservableObject { } init() { + _ = ImageManager.shared loadAllData() + migrateImagePaths() + + Task { + try? await Task.sleep(nanoseconds: 2_000_000_000) + await performImageMaintenance() + } } private func loadAllData() { @@ -181,11 +181,12 @@ class ClimbingDataManager: ObservableObject { attempts.removeAll { $0.problemId == problem.id } saveAttempts() + // Delete associated images + ImageManager.shared.deleteImages(atPaths: problem.imagePaths) + // Delete the problem problems.removeAll { $0.id == problem.id } saveProblems() - successMessage = "Problem deleted successfully" - clearMessageAfterDelay() } func problem(withId id: UUID) -> Problem? { @@ -770,7 +771,6 @@ struct AndroidAttempt: Codable { } } -// MARK: - Helper Functions extension ClimbingDataManager { private func collectReferencedImagePaths() -> Set { var imagePaths = Set() @@ -793,6 +793,137 @@ extension ClimbingDataManager { } } + private func migrateImagePaths() { + var needsUpdate = false + + let updatedProblems = problems.map { problem in + let migratedPaths = problem.imagePaths.compactMap { path in + // If it's already a relative path, keep it + if !path.hasPrefix("/") { + return path + } + + // For absolute paths, try to migrate to relative + let fileName = URL(fileURLWithPath: path).lastPathComponent + if ImageManager.shared.imageExists(atPath: fileName) { + needsUpdate = true + return fileName + } + + // If image doesn't exist, remove from paths + needsUpdate = true + return nil + } + + if migratedPaths != problem.imagePaths { + return problem.updated(imagePaths: migratedPaths) + } + return problem + } + + if needsUpdate { + problems = updatedProblems + saveProblems() + print("Migrated image paths for \(problems.count) problems") + } + } + + private func performImageMaintenance() async { + // Run maintenance in background + await Task.detached { + await ImageManager.shared.performMaintenance() + + // Log storage information for debugging + let info = await ImageManager.shared.getStorageInfo() + print( + "📊 Image Storage: \(info.primaryCount) primary, \(info.backupCount) backup, \(info.totalSize / 1024)KB total" + ) + }.value + } + + func manualImageMaintenance() { + Task { + await performImageMaintenance() + } + } + + func getImageStorageInfo() -> String { + let info = ImageManager.shared.getStorageInfo() + return """ + Image Storage Status: + • Primary: \(info.primaryCount) files + • Backup: \(info.backupCount) files + • Total Size: \(formatBytes(info.totalSize)) + """ + } + + func cleanupUnusedImages() { + // Get all image paths currently referenced in problems + let referencedImages = Set( + problems.flatMap { $0.imagePaths.map { ImageManager.shared.getRelativePath(from: $0) } } + ) + + // Get all files in storage + if let primaryFiles = try? FileManager.default.contentsOfDirectory( + atPath: ImageManager.shared.getImagesDirectoryPath()) + { + let orphanedFiles = primaryFiles.filter { !referencedImages.contains($0) } + + for fileName in orphanedFiles { + _ = ImageManager.shared.deleteImage(atPath: fileName) + } + + if !orphanedFiles.isEmpty { + print("🗑️ Cleaned up \(orphanedFiles.count) orphaned image files") + } + } + } + + private func formatBytes(_ bytes: Int64) -> String { + let kb = Double(bytes) / 1024.0 + let mb = kb / 1024.0 + + if mb >= 1.0 { + return String(format: "%.1f MB", mb) + } else { + return String(format: "%.0f KB", kb) + } + } + + func forceImageRecovery() { + print("🚨 User initiated force image recovery") + ImageManager.shared.forceRecoveryMigration() + + // Refresh the UI after recovery + objectWillChange.send() + } + + func emergencyImageRestore() { + print("🆘 User initiated emergency image restore") + ImageManager.shared.emergencyImageRestore() + + // Refresh the UI after restore + objectWillChange.send() + } + + func validateImageStorage() -> Bool { + return ImageManager.shared.validateStorageIntegrity() + } + + func getImageRecoveryStatus() -> String { + let isValid = validateImageStorage() + let info = ImageManager.shared.getStorageInfo() + + return """ + Image Storage Health: \(isValid ? "✅ Good" : "❌ Needs Recovery") + Primary Files: \(info.primaryCount) + Backup Files: \(info.backupCount) + Total Size: \(formatBytes(info.totalSize)) + + \(isValid ? "No action needed" : "Consider running Force Recovery") + """ + } + private func validateImportData(_ importData: ClimbDataExport) throws { if importData.gyms.isEmpty { throw NSError( @@ -802,7 +933,6 @@ extension ClimbingDataManager { } } -// MARK: - Preview Helper extension ClimbingDataManager { static var preview: ClimbingDataManager { let manager = ClimbingDataManager() diff --git a/ios/OpenClimb/Views/AddEdit/AddAttemptView.swift b/ios/OpenClimb/Views/AddEdit/AddAttemptView.swift index 1b5b390..a89a576 100644 --- a/ios/OpenClimb/Views/AddEdit/AddAttemptView.swift +++ b/ios/OpenClimb/Views/AddEdit/AddAttemptView.swift @@ -1,9 +1,3 @@ -// -// AddAttemptView.swift -// OpenClimb -// -// Created by OpenClimb on 2025-01-17. -// import SwiftUI @@ -99,14 +93,20 @@ struct AddAttemptView: View { } .padding(.vertical, 8) } else { - ForEach(activeProblems, id: \.id) { problem in - ProblemSelectionRow( - problem: problem, - isSelected: selectedProblem?.id == problem.id - ) { - selectedProblem = problem + LazyVGrid( + columns: Array(repeating: GridItem(.flexible(), spacing: 8), count: 2), + spacing: 8 + ) { + ForEach(activeProblems, id: \.id) { problem in + ProblemSelectionCard( + problem: problem, + isSelected: selectedProblem?.id == problem.id + ) { + selectedProblem = problem + } } } + .padding(.vertical, 8) Button("Create New Problem") { showingCreateProblem = true @@ -391,6 +391,197 @@ struct ProblemSelectionRow: View { } } +struct ProblemSelectionCard: View { + let problem: Problem + let isSelected: Bool + let action: () -> Void + @State private var showingExpandedView = false + + var body: some View { + VStack(spacing: 8) { + // Image section + ZStack { + if let firstImagePath = problem.imagePaths.first { + ProblemSelectionImageView(imagePath: firstImagePath) + } else { + RoundedRectangle(cornerRadius: 8) + .fill(.gray.opacity(0.2)) + .frame(height: 80) + .overlay { + Image(systemName: "mountain.2.fill") + .foregroundColor(.gray) + .font(.title2) + } + } + + // Selection indicator + VStack { + HStack { + Spacer() + if isSelected { + Image(systemName: "checkmark.circle.fill") + .foregroundColor(.white) + .background(Circle().fill(.blue)) + .font(.title3) + } + } + Spacer() + } + .padding(6) + + // Multiple images indicator + if problem.imagePaths.count > 1 { + VStack { + Spacer() + HStack { + Spacer() + Text("+\(problem.imagePaths.count - 1)") + .font(.caption2) + .fontWeight(.bold) + .foregroundColor(.white) + .padding(.horizontal, 6) + .padding(.vertical, 2) + .background( + RoundedRectangle(cornerRadius: 4) + .fill(.black.opacity(0.6)) + ) + } + } + .padding(6) + } + } + + // Problem info + VStack(alignment: .leading, spacing: 4) { + Text(problem.name ?? "Unnamed") + .font(.caption) + .fontWeight(.medium) + .lineLimit(1) + + Text(problem.difficulty.grade) + .font(.caption2) + .fontWeight(.bold) + .foregroundColor(.blue) + + if let location = problem.location { + Text(location) + .font(.caption2) + .foregroundColor(.secondary) + .lineLimit(1) + } + } + .frame(maxWidth: .infinity, alignment: .leading) + } + .padding(8) + .background( + RoundedRectangle(cornerRadius: 12) + .fill(isSelected ? .blue.opacity(0.1) : .gray.opacity(0.05)) + .stroke(isSelected ? .blue : .clear, lineWidth: 2) + ) + .contentShape(Rectangle()) + .onTapGesture { + if isSelected { + showingExpandedView = true + } else { + action() + } + } + .sheet(isPresented: $showingExpandedView) { + ProblemExpandedView(problem: problem) + } + } +} + +struct ProblemExpandedView: View { + let problem: Problem + @Environment(\.dismiss) private var dismiss + @State private var selectedImageIndex = 0 + + var body: some View { + NavigationView { + ScrollView { + VStack(alignment: .leading, spacing: 16) { + // Images + if !problem.imagePaths.isEmpty { + TabView(selection: $selectedImageIndex) { + ForEach(problem.imagePaths.indices, id: \.self) { index in + ProblemSelectionImageFullView(imagePath: problem.imagePaths[index]) + .tag(index) + } + } + .frame(height: 250) + .tabViewStyle(.page(indexDisplayMode: .always)) + } + + // Problem details + VStack(alignment: .leading, spacing: 12) { + Text(problem.name ?? "Unnamed Problem") + .font(.title2) + .fontWeight(.bold) + + HStack { + Text(problem.difficulty.grade) + .font(.title3) + .fontWeight(.bold) + .foregroundColor(.blue) + + Text(problem.climbType.displayName) + .font(.subheadline) + .foregroundColor(.secondary) + } + + if let location = problem.location, !location.isEmpty { + Label(location, systemImage: "location") + .font(.subheadline) + .foregroundColor(.secondary) + } + + if let setter = problem.setter, !setter.isEmpty { + Label(setter, systemImage: "person") + .font(.subheadline) + .foregroundColor(.secondary) + } + + if let description = problem.description, !description.isEmpty { + Text(description) + .font(.body) + } + + 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) + } + } + } + .padding(.horizontal) + } + } + .navigationTitle("Problem Details") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .navigationBarTrailing) { + Button("Done") { + dismiss() + } + } + } + } + } +} + struct EditAttemptView: View { let attempt: Attempt @EnvironmentObject var dataManager: ClimbingDataManager @@ -556,3 +747,131 @@ struct EditAttemptView: View { ) .environmentObject(ClimbingDataManager.preview) } + +struct ProblemSelectionImageView: View { + let imagePath: String + @State private var uiImage: UIImage? + @State private var isLoading = true + @State private var hasFailed = false + + var body: some View { + Group { + if let uiImage = uiImage { + Image(uiImage: uiImage) + .resizable() + .aspectRatio(contentMode: .fill) + .frame(height: 80) + .clipped() + .cornerRadius(8) + } else if hasFailed { + RoundedRectangle(cornerRadius: 8) + .fill(.gray.opacity(0.2)) + .frame(height: 80) + .overlay { + Image(systemName: "photo") + .foregroundColor(.gray) + .font(.title3) + } + } else { + RoundedRectangle(cornerRadius: 8) + .fill(.gray.opacity(0.3)) + .frame(height: 80) + .overlay { + ProgressView() + .scaleEffect(0.8) + } + } + } + .onAppear { + loadImage() + } + } + + private func loadImage() { + guard !imagePath.isEmpty else { + hasFailed = true + isLoading = false + return + } + + DispatchQueue.global(qos: .userInitiated).async { + if let data = ImageManager.shared.loadImageData(fromPath: imagePath), + let image = UIImage(data: data) + { + DispatchQueue.main.async { + self.uiImage = image + self.isLoading = false + } + } else { + DispatchQueue.main.async { + self.hasFailed = true + self.isLoading = false + } + } + } + } +} + +struct ProblemSelectionImageFullView: View { + let imagePath: String + @State private var uiImage: UIImage? + @State private var isLoading = true + @State private var hasFailed = false + + var body: some View { + Group { + if let uiImage = uiImage { + Image(uiImage: uiImage) + .resizable() + .aspectRatio(contentMode: .fit) + } else if hasFailed { + RoundedRectangle(cornerRadius: 12) + .fill(.gray.opacity(0.2)) + .frame(height: 250) + .overlay { + VStack(spacing: 8) { + Image(systemName: "photo") + .foregroundColor(.gray) + .font(.largeTitle) + Text("Image not available") + .foregroundColor(.gray) + } + } + } else { + RoundedRectangle(cornerRadius: 12) + .fill(.gray.opacity(0.3)) + .frame(height: 250) + .overlay { + ProgressView() + } + } + } + .onAppear { + loadImage() + } + } + + private func loadImage() { + guard !imagePath.isEmpty else { + hasFailed = true + isLoading = false + return + } + + DispatchQueue.global(qos: .userInitiated).async { + if let data = ImageManager.shared.loadImageData(fromPath: imagePath), + let image = UIImage(data: data) + { + DispatchQueue.main.async { + self.uiImage = image + self.isLoading = false + } + } else { + DispatchQueue.main.async { + self.hasFailed = true + self.isLoading = false + } + } + } + } +} diff --git a/ios/OpenClimb/Views/AddEdit/AddEditGymView.swift b/ios/OpenClimb/Views/AddEdit/AddEditGymView.swift index 98e29af..d0f5f69 100644 --- a/ios/OpenClimb/Views/AddEdit/AddEditGymView.swift +++ b/ios/OpenClimb/Views/AddEdit/AddEditGymView.swift @@ -1,9 +1,3 @@ -// -// AddEditGymView.swift -// OpenClimb -// -// Created by OpenClimb on 2025-01-17. -// import SwiftUI diff --git a/ios/OpenClimb/Views/AddEdit/AddEditProblemView.swift b/ios/OpenClimb/Views/AddEdit/AddEditProblemView.swift index 17b9a38..3460c3d 100644 --- a/ios/OpenClimb/Views/AddEdit/AddEditProblemView.swift +++ b/ios/OpenClimb/Views/AddEdit/AddEditProblemView.swift @@ -1,9 +1,3 @@ -// -// AddEditProblemView.swift -// OpenClimb -// -// Created by OpenClimb on 2025-01-17. -// import PhotosUI import SwiftUI @@ -459,19 +453,10 @@ struct AddEditProblemView: View { 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) + // Use ImageManager to save image + if let relativePath = ImageManager.shared.saveImageData(data) { + imagePaths.append(relativePath) imageData.append(data) - } catch { - print("Failed to save image: \(error)") } } } diff --git a/ios/OpenClimb/Views/AddEdit/AddEditSessionView.swift b/ios/OpenClimb/Views/AddEdit/AddEditSessionView.swift index cd34e3a..724b482 100644 --- a/ios/OpenClimb/Views/AddEdit/AddEditSessionView.swift +++ b/ios/OpenClimb/Views/AddEdit/AddEditSessionView.swift @@ -1,9 +1,3 @@ -// -// AddEditSessionView.swift -// OpenClimb -// -// Created by OpenClimb on 2025-01-17. -// import SwiftUI diff --git a/ios/OpenClimb/Views/AnalyticsView.swift b/ios/OpenClimb/Views/AnalyticsView.swift index 59e031e..22837e3 100644 --- a/ios/OpenClimb/Views/AnalyticsView.swift +++ b/ios/OpenClimb/Views/AnalyticsView.swift @@ -1,10 +1,3 @@ -// -// AnalyticsView.swift -// OpenClimb -// -// Created by OpenClimb on 2025-01-17. -// - import SwiftUI struct AnalyticsView: View { @@ -538,8 +531,6 @@ struct ProgressDataPoint { let difficultySystem: DifficultySystem } -// MARK: - Helper Functions - #Preview { AnalyticsView() .environmentObject(ClimbingDataManager.preview) diff --git a/ios/OpenClimb/Views/Detail/GymDetailView.swift b/ios/OpenClimb/Views/Detail/GymDetailView.swift index e1058a1..3b6e57f 100644 --- a/ios/OpenClimb/Views/Detail/GymDetailView.swift +++ b/ios/OpenClimb/Views/Detail/GymDetailView.swift @@ -1,9 +1,3 @@ -// -// GymDetailView.swift -// OpenClimb -// -// Created by OpenClimb on 2025-01-17. -// import SwiftUI diff --git a/ios/OpenClimb/Views/Detail/ProblemDetailView.swift b/ios/OpenClimb/Views/Detail/ProblemDetailView.swift index 32ac919..62dc277 100644 --- a/ios/OpenClimb/Views/Detail/ProblemDetailView.swift +++ b/ios/OpenClimb/Views/Detail/ProblemDetailView.swift @@ -1,9 +1,3 @@ -// -// ProblemDetailView.swift -// OpenClimb -// -// Created by OpenClimb on 2025-01-17. -// import SwiftUI @@ -296,21 +290,11 @@ struct PhotosSection: View { 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 - } + ProblemDetailImageView(imagePath: imagePaths[index]) + .onTapGesture { + selectedImageIndex = index + showingImageViewer = true + } } } .padding(.horizontal, 1) @@ -444,14 +428,8 @@ struct ImageViewerView: 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) + ProblemDetailImageFullView(imagePath: imagePaths[index]) + .tag(index) } } .tabViewStyle(.page(indexDisplayMode: .always)) @@ -468,6 +446,133 @@ struct ImageViewerView: View { } } +struct ProblemDetailImageView: View { + let imagePath: String + @State private var uiImage: UIImage? + @State private var isLoading = true + @State private var hasFailed = false + + var body: some View { + Group { + if let uiImage = uiImage { + Image(uiImage: uiImage) + .resizable() + .aspectRatio(contentMode: .fill) + .frame(width: 120, height: 120) + .clipped() + .cornerRadius(12) + } else if hasFailed { + RoundedRectangle(cornerRadius: 12) + .fill(.gray.opacity(0.2)) + .frame(width: 120, height: 120) + .overlay { + Image(systemName: "photo") + .foregroundColor(.gray) + .font(.title2) + } + } else { + RoundedRectangle(cornerRadius: 12) + .fill(.gray.opacity(0.3)) + .frame(width: 120, height: 120) + .overlay { + ProgressView() + } + } + } + .onAppear { + loadImage() + } + } + + private func loadImage() { + guard !imagePath.isEmpty else { + hasFailed = true + isLoading = false + return + } + + DispatchQueue.global(qos: .userInitiated).async { + if let data = ImageManager.shared.loadImageData(fromPath: imagePath), + let image = UIImage(data: data) + { + DispatchQueue.main.async { + self.uiImage = image + self.isLoading = false + } + } else { + DispatchQueue.main.async { + self.hasFailed = true + self.isLoading = false + } + } + } + } +} + +struct ProblemDetailImageFullView: View { + let imagePath: String + @State private var uiImage: UIImage? + @State private var isLoading = true + @State private var hasFailed = false + + var body: some View { + Group { + if let uiImage = uiImage { + Image(uiImage: uiImage) + .resizable() + .aspectRatio(contentMode: .fit) + } else if hasFailed { + Rectangle() + .fill(.gray.opacity(0.2)) + .frame(height: 250) + .overlay { + VStack(spacing: 8) { + Image(systemName: "photo") + .foregroundColor(.gray) + .font(.largeTitle) + Text("Image not available") + .foregroundColor(.gray) + } + } + } else { + Rectangle() + .fill(.gray.opacity(0.3)) + .frame(height: 250) + .overlay { + ProgressView() + } + } + } + .onAppear { + loadImage() + } + } + + private func loadImage() { + guard !imagePath.isEmpty else { + hasFailed = true + isLoading = false + return + } + + DispatchQueue.global(qos: .userInitiated).async { + if let data = ImageManager.shared.loadImageData(fromPath: imagePath), + let image = UIImage(data: data) + { + DispatchQueue.main.async { + self.uiImage = image + self.isLoading = false + } + } else { + DispatchQueue.main.async { + self.hasFailed = true + self.isLoading = false + } + } + } + } +} + #Preview { NavigationView { ProblemDetailView(problemId: UUID()) diff --git a/ios/OpenClimb/Views/Detail/SessionDetailView.swift b/ios/OpenClimb/Views/Detail/SessionDetailView.swift index e71d3b9..8caee31 100644 --- a/ios/OpenClimb/Views/Detail/SessionDetailView.swift +++ b/ios/OpenClimb/Views/Detail/SessionDetailView.swift @@ -1,10 +1,5 @@ -// -// SessionDetailView.swift -// OpenClimb -// -// Created by OpenClimb on 2025-01-17. -// +import Combine import SwiftUI struct SessionDetailView: View { @@ -14,6 +9,8 @@ struct SessionDetailView: View { @State private var showingDeleteAlert = false @State private var showingAddAttempt = false @State private var editingAttempt: Attempt? + @State private var attemptToDelete: Attempt? + @State private var currentTime = Date() private var session: ClimbSession? { dataManager.session(withId: sessionId) @@ -39,15 +36,20 @@ struct SessionDetailView: View { calculateSessionStats() } + private let timer = Timer.publish(every: 1, on: .main, in: .common).autoconnect() + var body: some View { ScrollView { LazyVStack(spacing: 20) { if let session = session, let gym = gym { - SessionHeaderCard(session: session, gym: gym, stats: sessionStats) + SessionHeaderCard( + session: session, gym: gym, stats: sessionStats, currentTime: currentTime) SessionStatsCard(stats: sessionStats) - AttemptsSection(attemptsWithProblems: attemptsWithProblems) + AttemptsSection( + attemptsWithProblems: attemptsWithProblems, + attemptToDelete: $attemptToDelete) } else { Text("Session not found") .foregroundColor(.secondary) @@ -55,6 +57,9 @@ struct SessionDetailView: View { } .padding() } + .onReceive(timer) { _ in + currentTime = Date() + } .navigationTitle("Session Details") .navigationBarTitleDisplayMode(.inline) .toolbar { @@ -80,6 +85,33 @@ struct SessionDetailView: View { } } } + .alert( + "Delete Attempt", + isPresented: Binding( + get: { attemptToDelete != nil }, + set: { if !$0 { attemptToDelete = nil } } + ) + ) { + Button("Cancel", role: .cancel) { + attemptToDelete = nil + } + Button("Delete", role: .destructive) { + if let attempt = attemptToDelete { + dataManager.deleteAttempt(attempt) + attemptToDelete = nil + } + } + } message: { + if let attempt = attemptToDelete, + let problem = dataManager.problem(withId: attempt.problemId) + { + Text( + "Are you sure you want to delete this attempt on \"\(problem.name ?? "Unknown Problem")\"? This action cannot be undone." + ) + } else { + Text("Are you sure you want to delete this attempt? This action cannot be undone.") + } + } .overlay(alignment: .bottomTrailing) { if session?.status == .active { Button(action: { showingAddAttempt = true }) { @@ -140,12 +172,26 @@ struct SessionDetailView: View { 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 ?? "")" + let difficulties = problems.map { $0.difficulty } + + // Group by difficulty system first + let groupedBySystem = Dictionary(grouping: difficulties) { $0.system } + + // For each system, find the range + let ranges = groupedBySystem.compactMap { (system, difficulties) -> String? in + let sortedDifficulties = difficulties.sorted() + guard let min = sortedDifficulties.first, let max = sortedDifficulties.last else { + return nil + } + + if min == max { + return min.grade + } else { + return "\(min.grade) - \(max.grade)" + } } + + return ranges.joined(separator: ", ") } } @@ -153,6 +199,7 @@ struct SessionHeaderCard: View { let session: ClimbSession let gym: Gym let stats: SessionStats + let currentTime: Date var body: some View { VStack(alignment: .leading, spacing: 16) { @@ -165,7 +212,13 @@ struct SessionHeaderCard: View { .font(.title2) .foregroundColor(.blue) - if let duration = session.duration { + if session.status == .active { + if let startTime = session.startTime { + Text("Duration: \(formatDuration(from: startTime, to: currentTime))") + .font(.subheadline) + .foregroundColor(.secondary) + } + } else if let duration = session.duration { Text("Duration: \(duration) minutes") .font(.subheadline) .foregroundColor(.secondary) @@ -209,6 +262,21 @@ struct SessionHeaderCard: View { formatter.dateStyle = .full return formatter.string(from: 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 SessionStatsCard: View { @@ -276,6 +344,7 @@ struct StatItem: View { struct AttemptsSection: View { let attemptsWithProblems: [(Attempt, Problem)] + @Binding var attemptToDelete: Attempt? @EnvironmentObject var dataManager: ClimbingDataManager @State private var editingAttempt: Attempt? @@ -311,6 +380,30 @@ struct AttemptsSection: View { ForEach(attemptsWithProblems.indices, id: \.self) { index in let (attempt, problem) = attemptsWithProblems[index] AttemptCard(attempt: attempt, problem: problem) + .background(.regularMaterial) + .cornerRadius(12) + .shadow(radius: 2) + .swipeActions(edge: .trailing, allowsFullSwipe: true) { + Button(role: .destructive) { + // Add haptic feedback for delete action + let impactFeedback = UIImpactFeedbackGenerator(style: .medium) + impactFeedback.impactOccurred() + attemptToDelete = attempt + } label: { + Label("Delete", systemImage: "trash") + } + .accessibilityLabel("Delete attempt") + .accessibilityHint("Removes this attempt from the session") + + Button { + editingAttempt = attempt + } label: { + Label("Edit", systemImage: "pencil") + } + .tint(.blue) + .accessibilityLabel("Edit attempt") + .accessibilityHint("Modify the details of this attempt") + } .onTapGesture { editingAttempt = attempt } @@ -327,8 +420,6 @@ struct AttemptsSection: View { 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) { @@ -353,15 +444,6 @@ struct AttemptCard: View { 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) - } } } @@ -378,19 +460,6 @@ struct AttemptCard: View { } } .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?") - } } } diff --git a/ios/OpenClimb/Views/GymsView.swift b/ios/OpenClimb/Views/GymsView.swift index f209f38..078dd4f 100644 --- a/ios/OpenClimb/Views/GymsView.swift +++ b/ios/OpenClimb/Views/GymsView.swift @@ -1,9 +1,3 @@ -// -// GymsView.swift -// OpenClimb -// -// Created by OpenClimb on 2025-01-17. -// import SwiftUI @@ -37,14 +31,47 @@ struct GymsView: View { struct GymsList: View { @EnvironmentObject var dataManager: ClimbingDataManager + @State private var gymToDelete: Gym? + @State private var gymToEdit: Gym? var body: some View { List(dataManager.gyms, id: \.id) { gym in NavigationLink(destination: GymDetailView(gymId: gym.id)) { GymRow(gym: gym) } + .swipeActions(edge: .trailing, allowsFullSwipe: false) { + Button(role: .destructive) { + gymToDelete = gym + } label: { + Label("Delete", systemImage: "trash") + } + + Button { + gymToEdit = gym + } label: { + Label("Edit", systemImage: "pencil") + } + .tint(.blue) + } + } + .alert("Delete Gym", isPresented: .constant(gymToDelete != nil)) { + Button("Cancel", role: .cancel) { + gymToDelete = nil + } + Button("Delete", role: .destructive) { + if let gym = gymToDelete { + dataManager.deleteGym(gym) + gymToDelete = nil + } + } + } message: { + Text( + "Are you sure you want to delete this gym? This will also delete all associated problems and sessions." + ) + } + .sheet(item: $gymToEdit) { gym in + AddEditGymView(gymId: gym.id) } - .listStyle(.plain) } } @@ -124,7 +151,7 @@ struct GymRow: View { .lineLimit(2) } } - .padding(.vertical, 4) + .padding(.vertical, 8) } } diff --git a/ios/OpenClimb/Views/ProblemsView.swift b/ios/OpenClimb/Views/ProblemsView.swift index a5a73d5..7014935 100644 --- a/ios/OpenClimb/Views/ProblemsView.swift +++ b/ios/OpenClimb/Views/ProblemsView.swift @@ -1,9 +1,3 @@ -// -// ProblemsView.swift -// OpenClimb -// -// Created by OpenClimb on 2025-01-17. -// import SwiftUI @@ -45,9 +39,13 @@ struct ProblemsView: View { NavigationView { VStack(spacing: 0) { if !dataManager.problems.isEmpty { - FilterSection() - .padding() - .background(.regularMaterial) + FilterSection( + selectedClimbType: $selectedClimbType, + selectedGym: $selectedGym, + filteredProblems: filteredProblems + ) + .padding() + .background(.regularMaterial) } if filteredProblems.isEmpty { @@ -79,8 +77,9 @@ struct ProblemsView: View { struct FilterSection: View { @EnvironmentObject var dataManager: ClimbingDataManager - @State private var selectedClimbType: ClimbType? - @State private var selectedGym: Gym? + @Binding var selectedClimbType: ClimbType? + @Binding var selectedGym: Gym? + let filteredProblems: [Problem] var body: some View { VStack(spacing: 12) { @@ -154,19 +153,6 @@ struct FilterSection: View { } } - 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 { @@ -195,14 +181,47 @@ struct FilterChip: View { struct ProblemsList: View { let problems: [Problem] @EnvironmentObject var dataManager: ClimbingDataManager + @State private var problemToDelete: Problem? + @State private var problemToEdit: Problem? var body: some View { List(problems) { problem in NavigationLink(destination: ProblemDetailView(problemId: problem.id)) { ProblemRow(problem: problem) } + .swipeActions(edge: .trailing, allowsFullSwipe: false) { + Button(role: .destructive) { + problemToDelete = problem + } label: { + Label("Delete", systemImage: "trash") + } + + Button { + problemToEdit = problem + } label: { + Label("Edit", systemImage: "pencil") + } + .tint(.blue) + } + } + .alert("Delete Problem", isPresented: .constant(problemToDelete != nil)) { + Button("Cancel", role: .cancel) { + problemToDelete = nil + } + Button("Delete", role: .destructive) { + if let problem = problemToDelete { + dataManager.deleteProblem(problem) + problemToDelete = nil + } + } + } message: { + Text( + "Are you sure you want to delete this problem? This will also delete all associated attempts." + ) + } + .sheet(item: $problemToEdit) { problem in + AddEditProblemView(problemId: problem.id) } - .listStyle(.plain) } } @@ -269,19 +288,10 @@ struct ProblemRow: View { 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) + ProblemImageView(imagePath: imagePath) } } + .padding(.horizontal, 4) } } @@ -292,7 +302,7 @@ struct ProblemRow: View { .fontWeight(.medium) } } - .padding(.vertical, 4) + .padding(.vertical, 8) } } @@ -356,6 +366,70 @@ struct EmptyProblemsView: View { } } +struct ProblemImageView: View { + let imagePath: String + @State private var uiImage: UIImage? + @State private var isLoading = true + @State private var hasFailed = false + + var body: some View { + Group { + if let uiImage = uiImage { + Image(uiImage: uiImage) + .resizable() + .aspectRatio(contentMode: .fill) + .frame(width: 60, height: 60) + .clipped() + .cornerRadius(8) + } else if hasFailed { + RoundedRectangle(cornerRadius: 8) + .fill(.gray.opacity(0.2)) + .frame(width: 60, height: 60) + .overlay { + Image(systemName: "photo") + .foregroundColor(.gray) + .font(.title3) + } + } else { + RoundedRectangle(cornerRadius: 8) + .fill(.gray.opacity(0.3)) + .frame(width: 60, height: 60) + .overlay { + ProgressView() + .scaleEffect(0.8) + } + } + } + .onAppear { + loadImage() + } + } + + private func loadImage() { + guard !imagePath.isEmpty else { + hasFailed = true + isLoading = false + return + } + + DispatchQueue.global(qos: .userInitiated).async { + if let data = ImageManager.shared.loadImageData(fromPath: imagePath), + let image = UIImage(data: data) + { + DispatchQueue.main.async { + self.uiImage = image + self.isLoading = false + } + } else { + DispatchQueue.main.async { + self.hasFailed = true + self.isLoading = false + } + } + } + } +} + #Preview { ProblemsView() .environmentObject(ClimbingDataManager.preview) diff --git a/ios/OpenClimb/Views/SessionsView.swift b/ios/OpenClimb/Views/SessionsView.swift index e2e5947..3e7f600 100644 --- a/ios/OpenClimb/Views/SessionsView.swift +++ b/ios/OpenClimb/Views/SessionsView.swift @@ -1,9 +1,3 @@ -// -// SessionsView.swift -// OpenClimb -// -// Created by OpenClimb on 2025-01-17. -// import Combine import SwiftUI @@ -127,6 +121,7 @@ struct ActiveSessionBanner: View { struct SessionsList: View { @EnvironmentObject var dataManager: ClimbingDataManager + @State private var sessionToDelete: ClimbSession? private var completedSessions: [ClimbSession] { dataManager.sessions @@ -139,8 +134,29 @@ struct SessionsList: View { NavigationLink(destination: SessionDetailView(sessionId: session.id)) { SessionRow(session: session) } + .swipeActions(edge: .trailing, allowsFullSwipe: false) { + Button(role: .destructive) { + sessionToDelete = session + } label: { + Label("Delete", systemImage: "trash") + } + } + } + .alert("Delete Session", isPresented: .constant(sessionToDelete != nil)) { + Button("Cancel", role: .cancel) { + sessionToDelete = nil + } + Button("Delete", role: .destructive) { + if let session = sessionToDelete { + dataManager.deleteSession(session) + sessionToDelete = nil + } + } + } message: { + Text( + "Are you sure you want to delete this session? This will also delete all attempts associated with this session." + ) } - .listStyle(.plain) } } @@ -179,7 +195,7 @@ struct SessionRow: View { .lineLimit(2) } } - .padding(.vertical, 4) + .padding(.vertical, 8) } private func formatDate(_ date: Date) -> String { diff --git a/ios/OpenClimb/Views/SettingsView.swift b/ios/OpenClimb/Views/SettingsView.swift index 018e9e3..38fbbe9 100644 --- a/ios/OpenClimb/Views/SettingsView.swift +++ b/ios/OpenClimb/Views/SettingsView.swift @@ -1,9 +1,3 @@ -// -// SettingsView.swift -// OpenClimb -// -// Created by OpenClimb on 2025-01-17. -// import SwiftUI import UniformTypeIdentifiers @@ -23,6 +17,8 @@ struct SettingsView: View { activeSheet: $activeSheet ) + ImageStorageSection() + AppInfoSection() } .navigationTitle("Settings") @@ -130,6 +126,96 @@ struct DataManagementSection: View { } } +struct ImageStorageSection: View { + @EnvironmentObject var dataManager: ClimbingDataManager + @State private var showingStorageInfo = false + @State private var storageInfo = "" + @State private var showingRecoveryAlert = false + @State private var showingEmergencyAlert = false + + var body: some View { + Section("Image Storage") { + // Storage Status + Button(action: { + storageInfo = dataManager.getImageRecoveryStatus() + showingStorageInfo = true + }) { + HStack { + Image(systemName: "info.circle") + .foregroundColor(.blue) + Text("Check Storage Health") + Spacer() + } + } + .foregroundColor(.primary) + + // Manual Maintenance + Button(action: { + dataManager.manualImageMaintenance() + }) { + HStack { + Image(systemName: "wrench.and.screwdriver") + .foregroundColor(.orange) + Text("Run Maintenance") + Spacer() + } + } + .foregroundColor(.primary) + + // Force Recovery + Button(action: { + showingRecoveryAlert = true + }) { + HStack { + Image(systemName: "arrow.clockwise") + .foregroundColor(.orange) + Text("Force Image Recovery") + Spacer() + } + } + .foregroundColor(.primary) + + // Emergency Restore + Button(action: { + showingEmergencyAlert = true + }) { + HStack { + Image(systemName: "exclamationmark.triangle") + .foregroundColor(.red) + Text("Emergency Restore") + Spacer() + } + } + .foregroundColor(.red) + } + .alert("Storage Information", isPresented: $showingStorageInfo) { + Button("OK") {} + } message: { + Text(storageInfo) + } + .alert("Force Image Recovery", isPresented: $showingRecoveryAlert) { + Button("Cancel", role: .cancel) {} + Button("Force Recovery", role: .destructive) { + dataManager.forceImageRecovery() + } + } message: { + Text( + "This will attempt to recover missing images from backups and legacy locations. Use this if images are missing after app updates or debug sessions." + ) + } + .alert("Emergency Restore", isPresented: $showingEmergencyAlert) { + Button("Cancel", role: .cancel) {} + Button("Emergency Restore", role: .destructive) { + dataManager.emergencyImageRestore() + } + } message: { + Text( + "This will restore all images from the backup directory, potentially overwriting current images. Only use this if normal recovery fails." + ) + } + } +} + struct AppInfoSection: View { private var appVersion: String { Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "Unknown"