diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 1ae4138..2fe5c30 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -5,11 +5,6 @@ on: paths: - "sync/**" - ".github/workflows/deploy.yml" - pull_request: - branches: [main] - paths: - - "sync/**" - - ".github/workflows/deploy.yml" jobs: build-and-push: diff --git a/README.md b/README.md index 861e258..470600e 100644 --- a/README.md +++ b/README.md @@ -2,21 +2,6 @@ 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.7.0 -- iOS: 1.2.0 -- Sync: 1.0.0 - -## Stability -- Clients: 8/10 -- Server: 10/10 -- Schema: 9/10 (No more breaking changes) - -## Self-Hosted Sync Server - -You can run your own sync server to keep your data in sync across devices. The server is lightweight and easy to set up. See the server docker-compose file for an example. - ## Download For Android do one of the following: @@ -28,6 +13,30 @@ For iOS: Download from the AppStore [here](https://apps.apple.com/ca/app/openclimb/id6752592783)! +## Self-Hosted Sync Server + +You can run your own sync server to keep your data in sync across devices. The server is lightweight and easy to set up using Docker. + +### Quick Start with Docker Compose + +1. Create a `.env` file with your configuration: +``` +IMAGE=git.atri.dad/atridad/openclimb-sync:latest +APP_PORT=8080 +AUTH_TOKEN=your-secure-auth-token-here +DATA_FILE=/data/openclimb.json +IMAGES_DIR=/data/images +ROOT_DIR=./openclimb-data +``` + +2. Use the provided `docker-compose.yml` in the `sync/` directory: +```bash +cd sync/ +docker-compose up -d +``` + +The server will be available at `http://localhost:8080`. Configure your clients with your server URL and auth token to start syncing. + ## Requirements - Android 12+ or iOS 17+ diff --git a/android/app/build_new.gradle.kts b/android/app/build_new.gradle.kts deleted file mode 100644 index 5fec1e4..0000000 --- a/android/app/build_new.gradle.kts +++ /dev/null @@ -1,98 +0,0 @@ -import org.jetbrains.kotlin.gradle.dsl.JvmTarget - -plugins { - alias(libs.plugins.android.application) - alias(libs.plugins.kotlin.android) - alias(libs.plugins.kotlin.compose) - alias(libs.plugins.kotlin.serialization) - alias(libs.plugins.ksp) -} - -android { - namespace = "com.atridad.openclimb" - compileSdk = 36 - - defaultConfig { - applicationId = "com.atridad.openclimb" - minSdk = 31 - targetSdk = 36 - versionCode = 27 - versionName = "1.6.0" - - testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" - } - - buildTypes { - release { - isMinifyEnabled = true - proguardFiles( - getDefaultProguardFile("proguard-android-optimize.txt"), - "proguard-rules.pro" - ) - } - } - compileOptions { - sourceCompatibility = JavaVersion.VERSION_17 - targetCompatibility = JavaVersion.VERSION_17 - } - - java { toolchain { languageVersion.set(JavaLanguageVersion.of(17)) } } - - buildFeatures { compose = true } -} - -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) - implementation(libs.androidx.material.icons.extended) - - // 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) - - // HTTP Client - implementation(libs.okhttp) - - // Testing - testImplementation(libs.junit) - testImplementation(libs.mockk) - testImplementation(libs.kotlinx.coroutines.test) - androidTestImplementation(libs.androidx.junit) - androidTestImplementation(libs.androidx.espresso.core) - androidTestImplementation(libs.androidx.test.core) - androidTestImplementation(libs.androidx.test.ext) - androidTestImplementation(libs.androidx.test.runner) - androidTestImplementation(libs.androidx.test.rules) - androidTestImplementation(platform(libs.androidx.compose.bom)) - androidTestImplementation(libs.androidx.ui.test.junit4) - debugImplementation(libs.androidx.ui.tooling) - debugImplementation(libs.androidx.ui.test.manifest) -} diff --git a/android/app/src/androidTest/java/com/atridad/openclimb/ExampleInstrumentedTest.kt b/android/app/src/androidTest/java/com/atridad/openclimb/ExampleInstrumentedTest.kt deleted file mode 100644 index e58e5a4..0000000 --- a/android/app/src/androidTest/java/com/atridad/openclimb/ExampleInstrumentedTest.kt +++ /dev/null @@ -1,24 +0,0 @@ -package com.atridad.openclimb - -import androidx.test.platform.app.InstrumentationRegistry -import androidx.test.ext.junit.runners.AndroidJUnit4 - -import org.junit.Test -import org.junit.runner.RunWith - -import org.junit.Assert.* - -/** - * Instrumented test, which will execute on an Android device. - * - * See [testing documentation](http://d.android.com/tools/testing). - */ -@RunWith(AndroidJUnit4::class) -class ExampleInstrumentedTest { - @Test - fun useAppContext() { - // Context of the app under test. - val appContext = InstrumentationRegistry.getInstrumentation().targetContext - assertEquals("com.atridad.openclimb", appContext.packageName) - } -} \ No newline at end of file diff --git a/android/app/src/main/java/com/atridad/openclimb/data/format/BackupFormat.kt b/android/app/src/main/java/com/atridad/openclimb/data/format/BackupFormat.kt index f8fae25..d97f448 100644 --- a/android/app/src/main/java/com/atridad/openclimb/data/format/BackupFormat.kt +++ b/android/app/src/main/java/com/atridad/openclimb/data/format/BackupFormat.kt @@ -3,7 +3,7 @@ package com.atridad.openclimb.data.format import com.atridad.openclimb.data.model.* import kotlinx.serialization.Serializable -/** Root structure for OpenClimb backup data */ +// Root structure for OpenClimb backup data @Serializable data class ClimbDataBackup( val exportedAt: String, @@ -15,7 +15,7 @@ data class ClimbDataBackup( val attempts: List ) -/** Platform-neutral gym representation for backup/restore */ +// Platform-neutral gym representation for backup/restore @Serializable data class BackupGym( val id: String, @@ -26,8 +26,8 @@ data class BackupGym( @kotlinx.serialization.SerialName("customDifficultyGrades") val customDifficultyGrades: List = emptyList(), val notes: String? = null, - val createdAt: String, // ISO 8601 format - val updatedAt: String // ISO 8601 format + val createdAt: String, + val updatedAt: String ) { companion object { /** Create BackupGym from native Android Gym model */ @@ -62,7 +62,7 @@ data class BackupGym( } } -/** Platform-neutral problem representation for backup/restore */ +// Platform-neutral problem representation for backup/restore @Serializable data class BackupProblem( val id: String, @@ -75,10 +75,10 @@ data class BackupProblem( val location: String? = null, val imagePaths: List? = null, val isActive: Boolean = true, - val dateSet: String? = null, // ISO 8601 format + val dateSet: String? = null, val notes: String? = null, - val createdAt: String, // ISO 8601 format - val updatedAt: String // ISO 8601 format + val createdAt: String, + val updatedAt: String ) { companion object { /** Create BackupProblem from native Android Problem model */ @@ -94,11 +94,7 @@ data class BackupProblem( location = problem.location, imagePaths = if (problem.imagePaths.isEmpty()) null - else - problem.imagePaths.map { path -> - // Store just the filename to match iOS format - path.substringAfterLast('/') - }, + else problem.imagePaths.map { path -> path.substringAfterLast('/') }, isActive = problem.isActive, dateSet = problem.dateSet, notes = problem.notes, @@ -134,19 +130,19 @@ data class BackupProblem( } } -/** Platform-neutral climb session representation for backup/restore */ +// Platform-neutral climb session representation for backup/restore @Serializable data class BackupClimbSession( val id: String, val gymId: String, - val date: String, // ISO 8601 format - val startTime: String? = null, // ISO 8601 format - val endTime: String? = null, // ISO 8601 format - val duration: Long? = null, // Duration in seconds + val date: String, + val startTime: String? = null, + val endTime: String? = null, + val duration: Long? = null, val status: SessionStatus, val notes: String? = null, - val createdAt: String, // ISO 8601 format - val updatedAt: String // ISO 8601 format + val createdAt: String, + val updatedAt: String ) { companion object { /** Create BackupClimbSession from native Android ClimbSession model */ @@ -183,7 +179,7 @@ data class BackupClimbSession( } } -/** Platform-neutral attempt representation for backup/restore */ +// Platform-neutral attempt representation for backup/restore @Serializable data class BackupAttempt( val id: String, @@ -192,10 +188,11 @@ data class BackupAttempt( val result: AttemptResult, val highestHold: String? = null, val notes: String? = null, - val duration: Long? = null, // Duration in seconds - val restTime: Long? = null, // Rest time in seconds - val timestamp: String, // ISO 8601 format - val createdAt: String // ISO 8601 format + val duration: Long? = null, + val restTime: Long? = null, + val timestamp: String, + val createdAt: String, + val updatedAt: String ) { companion object { /** Create BackupAttempt from native Android Attempt model */ diff --git a/android/app/src/main/java/com/atridad/openclimb/data/migration/ImageMigrationService.kt b/android/app/src/main/java/com/atridad/openclimb/data/migration/ImageMigrationService.kt index 0ec8afc..6b0d619 100644 --- a/android/app/src/main/java/com/atridad/openclimb/data/migration/ImageMigrationService.kt +++ b/android/app/src/main/java/com/atridad/openclimb/data/migration/ImageMigrationService.kt @@ -57,7 +57,7 @@ class ImageMigrationService(private val context: Context, private val repository migrationResults.putAll(problemMigrations) migratedCount += problemMigrations.size - // Update problem with new image paths + // Update image paths val newImagePaths = problem.imagePaths.map { oldPath -> problemMigrations[oldPath] ?: oldPath @@ -120,7 +120,7 @@ class ImageMigrationService(private val context: Context, private val repository continue } - // Check if filename follows our convention + // Check if filename follows convention if (ImageNamingUtils.isValidImageFilename(filename)) { validImages.add(imagePath) } else { diff --git a/android/app/src/main/java/com/atridad/openclimb/data/model/Attempt.kt b/android/app/src/main/java/com/atridad/openclimb/data/model/Attempt.kt index 794cf7d..0e35efa 100644 --- a/android/app/src/main/java/com/atridad/openclimb/data/model/Attempt.kt +++ b/android/app/src/main/java/com/atridad/openclimb/data/model/Attempt.kt @@ -39,11 +39,11 @@ data class Attempt( val sessionId: String, val problemId: String, val result: AttemptResult, - val highestHold: String? = null, // Description of the highest hold reached + val highestHold: String? = null, val notes: String? = null, - val duration: Long? = null, // Attempt duration in seconds - val restTime: Long? = null, // Rest time before this attempt in seconds - val timestamp: String, // When this attempt was made + val duration: Long? = null, + val restTime: Long? = null, + val timestamp: String, val createdAt: String ) { companion object { diff --git a/android/app/src/main/java/com/atridad/openclimb/data/model/DifficultySystem.kt b/android/app/src/main/java/com/atridad/openclimb/data/model/DifficultySystem.kt index fd58bb9..33c28aa 100644 --- a/android/app/src/main/java/com/atridad/openclimb/data/model/DifficultySystem.kt +++ b/android/app/src/main/java/com/atridad/openclimb/data/model/DifficultySystem.kt @@ -5,13 +5,11 @@ import kotlinx.serialization.Serializable @Serializable enum class DifficultySystem { // Bouldering - V_SCALE, // V-Scale (VB - V17) - FONT, // Fontainebleau (3 - 8C+) + V_SCALE, + FONT, // Rope - YDS, // Yosemite Decimal System (5.0 - 5.15d) - - // Custom difficulty systems + YDS, CUSTOM; /** Get the display name for the UI */ @@ -28,7 +26,7 @@ enum class DifficultySystem { when (this) { V_SCALE, FONT -> true YDS -> false - CUSTOM -> true // Custom is available for all + CUSTOM -> true } /** Check if this system is for rope climbing */ @@ -157,7 +155,6 @@ data class DifficultyGrade(val system: DifficultySystem, val grade: String, val if (grade == "VB") 0 else grade.removePrefix("V").toIntOrNull() ?: 0 } DifficultySystem.YDS -> { - // Simplified numeric mapping for YDS grades when { grade.startsWith("5.10") -> 10 + (grade.getOrNull(4)?.toString()?.hashCode()?.rem(4) ?: 0) @@ -175,7 +172,6 @@ data class DifficultyGrade(val system: DifficultySystem, val grade: String, val } } DifficultySystem.FONT -> { - // Simplified Font grade mapping when { grade.startsWith("6A") -> 6 grade.startsWith("6B") -> 7 @@ -209,24 +205,20 @@ data class DifficultyGrade(val system: DifficultySystem, val grade: String, val } private fun compareVScaleGrades(grade1: String, grade2: String): Int { - // Handle VB (easiest) specially if (grade1 == "VB" && grade2 != "VB") return -1 if (grade2 == "VB" && grade1 != "VB") return 1 if (grade1 == "VB" && grade2 == "VB") return 0 - // Extract numeric values for V grades val num1 = grade1.removePrefix("V").toIntOrNull() ?: 0 val num2 = grade2.removePrefix("V").toIntOrNull() ?: 0 return num1.compareTo(num2) } private fun compareFontGrades(grade1: String, grade2: String): Int { - // Simple string comparison for Font grades return grade1.compareTo(grade2) } private fun compareYDSGrades(grade1: String, grade2: String): Int { - // Simple string comparison for YDS grades return grade1.compareTo(grade2) } } diff --git a/android/app/src/main/java/com/atridad/openclimb/data/repository/ClimbRepository.kt b/android/app/src/main/java/com/atridad/openclimb/data/repository/ClimbRepository.kt index 01aced1..453df81 100644 --- a/android/app/src/main/java/com/atridad/openclimb/data/repository/ClimbRepository.kt +++ b/android/app/src/main/java/com/atridad/openclimb/data/repository/ClimbRepository.kt @@ -23,7 +23,6 @@ class ClimbRepository(database: OpenClimbDatabase, private val context: Context) private val attemptDao = database.attemptDao() private val dataStateManager = DataStateManager(context) - // Callback interface for auto-sync functionality private var autoSyncCallback: (() -> Unit)? = null private val json = Json { @@ -125,16 +124,13 @@ class ClimbRepository(database: OpenClimbDatabase, private val context: Context) suspend fun exportAllDataToZipUri(context: Context, uri: android.net.Uri) { try { - // Collect all data val allGyms = gymDao.getAllGyms().first() val allProblems = problemDao.getAllProblems().first() val allSessions = sessionDao.getAllSessions().first() val allAttempts = attemptDao.getAllAttempts().first() - // Validate data integrity before export validateDataIntegrity(allGyms, allProblems, allSessions, allAttempts) - // Create backup data using platform-neutral format val backupData = ClimbDataBackup( exportedAt = DateFormatUtils.nowISO8601(), @@ -146,7 +142,6 @@ class ClimbRepository(database: OpenClimbDatabase, private val context: Context) attempts = allAttempts.map { BackupAttempt.fromAttempt(it) } ) - // Collect all referenced image paths and validate they exist val referencedImagePaths = allProblems.flatMap { it.imagePaths }.toSet() val validImagePaths = referencedImagePaths @@ -177,20 +172,16 @@ class ClimbRepository(database: OpenClimbDatabase, private val context: Context) suspend fun importDataFromZip(file: File) { try { - // Validate the ZIP file if (!file.exists() || file.length() == 0L) { throw Exception("Invalid ZIP file: file is empty or doesn't exist") } - // Extract and validate the ZIP contents val importResult = ZipExportImportUtils.extractImportZip(context, file) - // Validate JSON content if (importResult.jsonContent.isBlank()) { throw Exception("Invalid ZIP file: no data.json found or empty content") } - // Parse and validate the data structure val importData = try { json.decodeFromString(importResult.jsonContent) @@ -198,17 +189,13 @@ class ClimbRepository(database: OpenClimbDatabase, private val context: Context) throw Exception("Invalid data format: ${e.message}") } - // Validate data integrity validateImportData(importData) - // Clear existing data to avoid conflicts attemptDao.deleteAllAttempts() sessionDao.deleteAllSessions() problemDao.deleteAllProblems() gymDao.deleteAllGyms() - // Import gyms first (problems depend on gyms) - use DAO directly to avoid multiple data - // state updates importData.gyms.forEach { backupGym -> try { gymDao.insertGym(backupGym.toGym()) @@ -217,14 +204,12 @@ class ClimbRepository(database: OpenClimbDatabase, private val context: Context) } } - // Import problems with updated image paths val updatedBackupProblems = ZipExportImportUtils.updateProblemImagePaths( importData.problems, importResult.importedImagePaths ) - // Import problems (depends on gyms) - use DAO directly updatedBackupProblems.forEach { backupProblem -> try { problemDao.insertProblem(backupProblem.toProblem()) @@ -235,7 +220,6 @@ class ClimbRepository(database: OpenClimbDatabase, private val context: Context) } } - // Import sessions - use DAO directly importData.sessions.forEach { backupSession -> try { sessionDao.insertSession(backupSession.toClimbSession()) @@ -244,7 +228,6 @@ class ClimbRepository(database: OpenClimbDatabase, private val context: Context) } } - // Import attempts last (depends on problems and sessions) - use DAO directly importData.attempts.forEach { backupAttempt -> try { attemptDao.insertAttempt(backupAttempt.toAttempt()) @@ -253,7 +236,6 @@ class ClimbRepository(database: OpenClimbDatabase, private val context: Context) } } - // Update data state once at the end to current time since we just imported new data dataStateManager.updateDataState() } catch (e: Exception) { throw Exception("Import failed: ${e.message}") @@ -282,7 +264,6 @@ class ClimbRepository(database: OpenClimbDatabase, private val context: Context) sessions: List, attempts: List ) { - // Validate that all problems reference valid gyms val gymIds = gyms.map { it.id }.toSet() val invalidProblems = problems.filter { it.gymId !in gymIds } if (invalidProblems.isNotEmpty()) { @@ -291,7 +272,6 @@ class ClimbRepository(database: OpenClimbDatabase, private val context: Context) ) } - // Validate that all sessions reference valid gyms val invalidSessions = sessions.filter { it.gymId !in gymIds } if (invalidSessions.isNotEmpty()) { throw Exception( @@ -299,7 +279,6 @@ class ClimbRepository(database: OpenClimbDatabase, private val context: Context) ) } - // Validate that all attempts reference valid problems and sessions val problemIds = problems.map { it.id }.toSet() val sessionIds = sessions.map { it.id }.toSet() @@ -321,7 +300,6 @@ class ClimbRepository(database: OpenClimbDatabase, private val context: Context) throw Exception("Import data is invalid: no version information") } - // Check for reasonable data sizes to prevent malicious imports if (importData.gyms.size > 1000 || importData.problems.size > 10000 || importData.sessions.size > 10000 || @@ -333,27 +311,22 @@ class ClimbRepository(database: OpenClimbDatabase, private val context: Context) suspend fun resetAllData() { try { - // Temporarily disable auto-sync during reset val originalCallback = autoSyncCallback autoSyncCallback = null - // Clear all data from database attemptDao.deleteAllAttempts() sessionDao.deleteAllSessions() problemDao.deleteAllProblems() gymDao.deleteAllGyms() - // Clear all images from storage clearAllImages() - // Restore auto-sync callback autoSyncCallback = originalCallback } catch (e: Exception) { throw Exception("Reset failed: ${e.message}") } } - // Import methods that bypass auto-sync to avoid triggering sync during data restoration suspend fun insertGymWithoutSync(gym: Gym) { gymDao.insertGym(gym) dataStateManager.updateDataState() @@ -376,7 +349,6 @@ class ClimbRepository(database: OpenClimbDatabase, private val context: Context) private fun clearAllImages() { try { - // Get the images directory val imagesDir = File(context.filesDir, "images") if (imagesDir.exists() && imagesDir.isDirectory) { val deletedCount = imagesDir.listFiles()?.size ?: 0 diff --git a/android/app/src/main/java/com/atridad/openclimb/data/state/DataStateManager.kt b/android/app/src/main/java/com/atridad/openclimb/data/state/DataStateManager.kt index 7fedf7f..01ad1db 100644 --- a/android/app/src/main/java/com/atridad/openclimb/data/state/DataStateManager.kt +++ b/android/app/src/main/java/com/atridad/openclimb/data/state/DataStateManager.kt @@ -22,7 +22,6 @@ class DataStateManager(context: Context) { context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE) init { - // Initialize with current timestamp if this is the first time if (!isInitialized()) { updateDataState() markAsInitialized() diff --git a/android/app/src/main/java/com/atridad/openclimb/data/sync/SyncService.kt b/android/app/src/main/java/com/atridad/openclimb/data/sync/SyncService.kt index 45a033b..7e08d93 100644 --- a/android/app/src/main/java/com/atridad/openclimb/data/sync/SyncService.kt +++ b/android/app/src/main/java/com/atridad/openclimb/data/sync/SyncService.kt @@ -3,6 +3,7 @@ package com.atridad.openclimb.data.sync import android.content.Context import android.content.SharedPreferences import android.util.Log +import androidx.core.content.edit import com.atridad.openclimb.data.format.BackupAttempt import com.atridad.openclimb.data.format.BackupClimbSession import com.atridad.openclimb.data.format.BackupGym @@ -31,7 +32,6 @@ import okhttp3.MediaType.Companion.toMediaType import okhttp3.OkHttpClient import okhttp3.Request import okhttp3.RequestBody.Companion.toRequestBody -import androidx.core.content.edit class SyncService(private val context: Context, private val repository: ClimbRepository) { @@ -61,7 +61,7 @@ class SyncService(private val context: Context, private val repository: ClimbRep coerceInputValues = true } - // State flows + // State private val _isSyncing = MutableStateFlow(false) val isSyncing: StateFlow = _isSyncing.asStateFlow() @@ -109,15 +109,11 @@ class SyncService(private val context: Context, private val repository: ClimbRep } init { - // Initialize state from preferences _isConnected.value = sharedPreferences.getBoolean(Keys.IS_CONNECTED, false) _lastSyncTime.value = sharedPreferences.getString(Keys.LAST_SYNC_TIME, null) - // Register auto-sync callback with repository repository.setAutoSyncCallback { - kotlinx.coroutines.CoroutineScope(Dispatchers.IO).launch { - triggerAutoSync() - } + kotlinx.coroutines.CoroutineScope(Dispatchers.IO).launch { triggerAutoSync() } } } @@ -153,7 +149,6 @@ class SyncService(private val context: Context, private val repository: ClimbRep "Server backup contains: gyms=${backup.gyms.size}, problems=${backup.problems.size}, sessions=${backup.sessions.size}, attempts=${backup.attempts.size}" ) - // Log problems with images backup.problems.forEach { problem -> val imageCount = problem.imagePaths?.size ?: 0 if (imageCount > 0) { @@ -236,8 +231,6 @@ class SyncService(private val context: Context, private val repository: ClimbRep throw SyncException.NotConfigured } - // Server expects filename as query parameter and raw image data in body - // Extract just the filename without directory path val justFilename = filename.substringAfterLast('/') val requestBody = imageData.toRequestBody("image/*".toMediaType()) @@ -252,7 +245,7 @@ class SyncService(private val context: Context, private val repository: ClimbRep val response = httpClient.newCall(request).execute() when (response.code) { - 200 -> Unit // Success + 200 -> Unit 401 -> throw SyncException.Unauthorized else -> { val errorBody = response.body?.string() ?: "No error details" @@ -325,33 +318,27 @@ class SyncService(private val context: Context, private val repository: ClimbRep throw SyncException.NotConnected } - // Prevent concurrent sync operations syncMutex.withLock { _isSyncing.value = true _syncError.value = null try { - // Fix existing image paths first Log.d(TAG, "Fixing existing image paths before sync") val pathFixSuccess = fixImagePaths() if (!pathFixSuccess) { Log.w(TAG, "Image path fix failed, but continuing with sync") } - // Migrate images to consistent naming second Log.d(TAG, "Performing image migration before sync") val migrationSuccess = migrateImagesForSync() if (!migrationSuccess) { Log.w(TAG, "Image migration failed, but continuing with sync") } - // Get local backup data val localBackup = createBackupFromRepository() - // Download server data val serverBackup = downloadData() - // Check if we have any local data val hasLocalData = localBackup.gyms.isNotEmpty() || localBackup.problems.isNotEmpty() || @@ -366,21 +353,18 @@ class SyncService(private val context: Context, private val repository: ClimbRep when { !hasLocalData && hasServerData -> { - // Case 1: No local data - do full restore from server Log.d(TAG, "No local data found, performing full restore from server") val imagePathMapping = syncImagesFromServer(serverBackup) importBackupToRepository(serverBackup, imagePathMapping) Log.d(TAG, "Full restore completed") } hasLocalData && !hasServerData -> { - // Case 2: No server data - upload local data to server Log.d(TAG, "No server data found, uploading local data to server") uploadData(localBackup) syncImagesForBackup(localBackup) Log.d(TAG, "Initial upload completed") } hasLocalData && hasServerData -> { - // Case 3: Both have data - compare timestamps (last writer wins) val localTimestamp = parseISO8601ToMillis(localBackup.exportedAt) val serverTimestamp = parseISO8601ToMillis(serverBackup.exportedAt) @@ -390,19 +374,16 @@ class SyncService(private val context: Context, private val repository: ClimbRep ) if (localTimestamp > serverTimestamp) { - // Local is newer - replace server with local data Log.d(TAG, "Local data is newer, replacing server content") uploadData(localBackup) syncImagesForBackup(localBackup) Log.d(TAG, "Server replaced with local data") } else if (serverTimestamp > localTimestamp) { - // Server is newer - replace local with server data Log.d(TAG, "Server data is newer, replacing local content") val imagePathMapping = syncImagesFromServer(serverBackup) importBackupToRepository(serverBackup, imagePathMapping) Log.d(TAG, "Local data replaced with server data") } else { - // Timestamps are equal - no sync needed Log.d(TAG, "Data is in sync (timestamps equal), no action needed") } } @@ -411,7 +392,6 @@ class SyncService(private val context: Context, private val repository: ClimbRep } } - // Update last sync time val now = DateFormatUtils.nowISO8601() _lastSyncTime.value = now sharedPreferences.edit().putString(Keys.LAST_SYNC_TIME, now).apply() @@ -447,13 +427,11 @@ class SyncService(private val context: Context, private val repository: ClimbRep Log.d(TAG, "Attempting to download image: $imagePath") val imageData = downloadImage(imagePath) - // Extract filename and ensure it follows our naming convention val serverFilename = imagePath.substringAfterLast('/') val consistentFilename = if (ImageNamingUtils.isValidImageFilename(serverFilename)) { serverFilename } else { - // Generate consistent filename using problem ID and index ImageNamingUtils.generateImageFilename(problem.id, index) } @@ -465,7 +443,6 @@ class SyncService(private val context: Context, private val repository: ClimbRep ) if (localImagePath != null) { - // Map original server filename to the full local relative path imagePathMapping[serverFilename] = localImagePath downloadedImages++ Log.d( @@ -516,12 +493,10 @@ class SyncService(private val context: Context, private val repository: ClimbRep val imageData = imageFile.readBytes() val filename = imagePath.substringAfterLast('/') - // Ensure filename follows our naming convention val consistentFilename = if (ImageNamingUtils.isValidImageFilename(filename)) { filename } else { - // Generate consistent filename and rename the local file val newFilename = ImageNamingUtils.generateImageFilename( problem.id, @@ -533,7 +508,6 @@ class SyncService(private val context: Context, private val repository: ClimbRep TAG, "Renamed local image file: $filename -> $newFilename" ) - // Update the problem's image path in memory for next sync newFilename } else { Log.w( @@ -589,10 +563,8 @@ class SyncService(private val context: Context, private val repository: ClimbRep backup: ClimbDataBackup, imagePathMapping: Map = emptyMap() ) { - // Clear existing data to avoid conflicts repository.resetAllData() - // Import gyms first (problems depend on gyms) backup.gyms.forEach { backupGym -> try { val gym = backupGym.toGym() @@ -600,21 +572,18 @@ class SyncService(private val context: Context, private val repository: ClimbRep repository.insertGymWithoutSync(gym) } catch (e: Exception) { Log.e(TAG, "Failed to import gym '${backupGym.name}': ${e.message}") - throw e // Stop import if gym fails since problems depend on it + throw e } } - // Import problems with updated image paths backup.problems.forEach { backupProblem -> try { val updatedProblem = if (imagePathMapping.isNotEmpty()) { val newImagePaths = backupProblem.imagePaths?.map { oldPath -> - // Extract filename and check mapping val filename = oldPath.substringAfterLast('/') - // Use mapped full path or fallback to consistent naming - // with full path + imagePathMapping[filename] ?: if (ImageNamingUtils.isValidImageFilename( filename @@ -622,8 +591,6 @@ class SyncService(private val context: Context, private val repository: ClimbRep ) { "problem_images/$filename" } else { - // Generate consistent filename as fallback with - // full path val index = backupProblem.imagePaths.indexOf( oldPath @@ -647,7 +614,6 @@ class SyncService(private val context: Context, private val repository: ClimbRep } } - // Import sessions backup.sessions.forEach { backupSession -> try { repository.insertSessionWithoutSync(backupSession.toClimbSession()) @@ -656,7 +622,6 @@ class SyncService(private val context: Context, private val repository: ClimbRep } } - // Import attempts last backup.attempts.forEach { backupAttempt -> try { repository.insertAttemptWithoutSync(backupAttempt.toAttempt()) @@ -665,7 +630,6 @@ class SyncService(private val context: Context, private val repository: ClimbRep } } - // Update local data state to match imported data timestamp dataStateManager.setLastModified(backup.exportedAt) Log.d(TAG, "Data state synchronized to imported timestamp: ${backup.exportedAt}") } @@ -697,7 +661,6 @@ class SyncService(private val context: Context, private val repository: ClimbRep val fixedPaths = problem.imagePaths.map { path -> if (!path.startsWith("problem_images/") && !path.contains("/")) { - // Just a filename, add the directory prefix val fixedPath = "problem_images/$path" Log.d(TAG, "Fixed path: $path -> $fixedPath") fixedCount++ @@ -798,7 +761,6 @@ class SyncService(private val context: Context, private val repository: ClimbRep return } - // Check if sync is already running to prevent duplicate attempts if (_isSyncing.value) { Log.d(TAG, "Sync already in progress, skipping auto-sync") return diff --git a/android/app/src/main/java/com/atridad/openclimb/navigation/Screen.kt b/android/app/src/main/java/com/atridad/openclimb/navigation/Screen.kt index 1a5ef77..c459e07 100644 --- a/android/app/src/main/java/com/atridad/openclimb/navigation/Screen.kt +++ b/android/app/src/main/java/com/atridad/openclimb/navigation/Screen.kt @@ -4,39 +4,27 @@ import kotlinx.serialization.Serializable @Serializable sealed class Screen { - @Serializable - data object Sessions : Screen() - - @Serializable - data object Problems : Screen() - - @Serializable - data object Analytics : Screen() - - @Serializable - data object Gyms : Screen() - - @Serializable - data object Settings : Screen() - - // Detail screens - @Serializable - data class SessionDetail(val sessionId: String) : Screen() - - @Serializable - data class ProblemDetail(val problemId: String) : Screen() - - @Serializable - data class GymDetail(val gymId: String) : Screen() - - @Serializable - data class AddEditGym(val gymId: String? = null) : Screen() - + @Serializable data object Sessions : Screen() + + @Serializable data object Problems : Screen() + + @Serializable data object Analytics : Screen() + + @Serializable data object Gyms : Screen() + + @Serializable data object Settings : Screen() + + @Serializable data class SessionDetail(val sessionId: String) : Screen() + + @Serializable data class ProblemDetail(val problemId: String) : Screen() + + @Serializable data class GymDetail(val gymId: String) : Screen() + + @Serializable data class AddEditGym(val gymId: String? = null) : Screen() + @Serializable data class AddEditProblem(val problemId: String? = null, val gymId: String? = null) : Screen() - + @Serializable data class AddEditSession(val sessionId: String? = null, val gymId: String? = null) : Screen() - - } diff --git a/android/app/src/main/java/com/atridad/openclimb/ui/OpenClimbApp.kt b/android/app/src/main/java/com/atridad/openclimb/ui/OpenClimbApp.kt index a8bbca2..1e42333 100644 --- a/android/app/src/main/java/com/atridad/openclimb/ui/OpenClimbApp.kt +++ b/android/app/src/main/java/com/atridad/openclimb/ui/OpenClimbApp.kt @@ -47,11 +47,9 @@ fun OpenClimbApp( val viewModel: ClimbViewModel = viewModel(factory = ClimbViewModelFactory(repository, syncService)) - // Notification permission state var showNotificationPermissionDialog by remember { mutableStateOf(false) } var hasCheckedNotificationPermission by remember { mutableStateOf(false) } - // Permission launcher val permissionLauncher = rememberLauncherForActivityResult( contract = ActivityResultContracts.RequestPermission() @@ -75,13 +73,11 @@ fun OpenClimbApp( LaunchedEffect(Unit) { viewModel.ensureSessionTrackingServiceRunning(context) } - // Trigger auto-sync on app launch LaunchedEffect(Unit) { syncService.triggerAutoSync() } val activeSession by viewModel.activeSession.collectAsState() val gyms by viewModel.gyms.collectAsState() - // Update last used gym when gyms change LaunchedEffect(gyms) { if (gyms.isNotEmpty() && lastUsedGym == null) { lastUsedGym = viewModel.getLastUsedGym() @@ -116,7 +112,6 @@ fun OpenClimbApp( } } - // Process shortcut actions after data is loaded LaunchedEffect(shortcutAction, activeSession, gyms, lastUsedGym) { if (shortcutAction == AppShortcutManager.ACTION_START_SESSION && gyms.isNotEmpty()) { android.util.Log.d( @@ -140,7 +135,6 @@ fun OpenClimbApp( ) viewModel.startSession(context, gyms.first().id) } else { - // Try to get the last used gym from the intent or fallback to state val targetGym = lastUsedGymId?.let { gymId -> gyms.find { it.id == gymId } } ?: lastUsedGym @@ -167,7 +161,6 @@ fun OpenClimbApp( ) } - // Clear the shortcut action after processing to prevent repeated execution onShortcutActionProcessed() } } @@ -215,8 +208,6 @@ fun OpenClimbApp( if (gyms.size == 1) { viewModel.startSession(context, gyms.first().id) } else { - // Always show gym selection for FAB when - // multiple gyms navController.navigate(Screen.AddEditSession()) } } @@ -362,7 +353,6 @@ fun OpenClimbApp( } } - // Notification permission dialog if (showNotificationPermissionDialog) { NotificationPermissionDialog( onDismiss = { showNotificationPermissionDialog = false }, @@ -399,10 +389,7 @@ fun OpenClimbBottomNavigation(navController: NavHostController) { selected = isSelected, onClick = { navController.navigate(item.screen) { - // Clear the entire back stack and go to the selected tab's root screen popUpTo(0) { inclusive = true } - // Avoid multiple copies of the same destination when - // reselecting the same item launchSingleTop = true // Don't restore state - always start fresh when switching tabs restoreState = false diff --git a/android/app/src/main/java/com/atridad/openclimb/ui/components/BarChart.kt b/android/app/src/main/java/com/atridad/openclimb/ui/components/BarChart.kt index 01b2732..cf2f7d2 100644 --- a/android/app/src/main/java/com/atridad/openclimb/ui/components/BarChart.kt +++ b/android/app/src/main/java/com/atridad/openclimb/ui/components/BarChart.kt @@ -54,15 +54,15 @@ fun BarChart( val chartWidth = size.width - padding * 2 val chartHeight = size.height - padding * 2 - // Sort data by grade numeric value for proper ordering + // Sort data by grade numeric value val sortedData = data.sortedBy { it.gradeNumeric } - // Calculate max value for scaling + // Calculate max value val maxValue = sortedData.maxOfOrNull { it.value } ?: 1 - // Calculate bar dimensions + // Bar dimensions val barCount = sortedData.size - val totalSpacing = chartWidth * 0.2f // 20% of width for spacing + val totalSpacing = chartWidth * 0.2f val barSpacing = if (barCount > 1) totalSpacing / (barCount + 1) else totalSpacing / 2 val barWidth = (chartWidth - totalSpacing) / barCount @@ -106,25 +106,25 @@ fun BarChart( size = androidx.compose.ui.geometry.Size(barWidth, barHeight) ) - // Draw value on top of bar (if there's space) + // Draw value on bar if (dataPoint.value > 0) { val valueText = dataPoint.value.toString() val textStyle = TextStyle(color = style.textColor, fontSize = 10.sp) val textSize = textMeasurer.measure(valueText, textStyle) - // Position text on top of bar or inside if bar is tall enough + // Position text val textY = if (barHeight > textSize.size.height + 8.dp.toPx()) { - barY + 8.dp.toPx() // Inside bar + barY + 8.dp.toPx() } else { - barY - 4.dp.toPx() // Above bar + barY - 4.dp.toPx() } val textColor = if (barHeight > textSize.size.height + 8.dp.toPx()) { - Color.White // White text inside bar + Color.White } else { - style.textColor // Regular color above bar + style.textColor } drawText( @@ -166,7 +166,7 @@ private fun DrawScope.drawGrid( ) { val textStyle = TextStyle(color = textColor, fontSize = 10.sp) - // Draw horizontal grid lines (Y-axis) + // Horizontal grid lines val gridLines = when { maxValue <= 5 -> (0..maxValue).toList() diff --git a/android/app/src/main/java/com/atridad/openclimb/utils/DateFormatUtils.kt b/android/app/src/main/java/com/atridad/openclimb/utils/DateFormatUtils.kt index d7efbb9..17058fe 100644 --- a/android/app/src/main/java/com/atridad/openclimb/utils/DateFormatUtils.kt +++ b/android/app/src/main/java/com/atridad/openclimb/utils/DateFormatUtils.kt @@ -6,40 +6,26 @@ import java.time.format.DateTimeFormatter object DateFormatUtils { - /** - * ISO 8601 formatter matching iOS date format exactly Produces dates like: - * "2025-09-07T22:00:40.014Z" - */ + // ISO 8601 formatter matching iOS date format exactly private val ISO_FORMATTER = DateTimeFormatter.ofPattern("yyyy-MM-dd'T'HH:mm:ss.SSSX").withZone(ZoneOffset.UTC) - /** - * Get current timestamp in iOS-compatible ISO 8601 format - * @return Current timestamp as "2025-09-07T22:00:40.014Z" - */ + /** Get current timestamp in iOS-compatible ISO 8601 format */ fun nowISO8601(): String { return ISO_FORMATTER.format(Instant.now()) } - /** - * Format an Instant to iOS-compatible ISO 8601 format - * @param instant The instant to format - * @return Formatted timestamp as "2025-09-07T22:00:40.014Z" - */ + /** Format an Instant to iOS-compatible ISO 8601 format */ fun formatISO8601(instant: Instant): String { return ISO_FORMATTER.format(instant) } - /** - * Parse an iOS-compatible ISO 8601 date string back to Instant - * @param dateString ISO 8601 formatted date string - * @return Instant object, or null if parsing fails - */ + /** Parse an iOS-compatible ISO 8601 date string back to Instant */ fun parseISO8601(dateString: String): Instant? { return try { Instant.from(ISO_FORMATTER.parse(dateString)) } catch (e: Exception) { - // Fallback - try standard Instant parsing + try { Instant.parse(dateString) } catch (e2: Exception) { @@ -48,20 +34,12 @@ object DateFormatUtils { } } - /** - * Validate that a date string matches the expected iOS format - * @param dateString The date string to validate - * @return True if the format matches iOS expectations - */ + /** Validate that a date string matches the expected iOS format */ fun isValidISO8601(dateString: String): Boolean { return parseISO8601(dateString) != null } - /** - * Convert milliseconds timestamp to iOS-compatible ISO 8601 format - * @param millis Milliseconds since epoch - * @return Formatted timestamp as "2025-09-07T22:00:40.014Z" - */ + /** Convert milliseconds timestamp to iOS-compatible ISO 8601 format */ fun millisToISO8601(millis: Long): String { return ISO_FORMATTER.format(Instant.ofEpochMilli(millis)) } diff --git a/android/app/src/main/java/com/atridad/openclimb/utils/ImageNamingUtils.kt b/android/app/src/main/java/com/atridad/openclimb/utils/ImageNamingUtils.kt index 94eac0f..73c907b 100644 --- a/android/app/src/main/java/com/atridad/openclimb/utils/ImageNamingUtils.kt +++ b/android/app/src/main/java/com/atridad/openclimb/utils/ImageNamingUtils.kt @@ -12,15 +12,7 @@ object ImageNamingUtils { private const val IMAGE_EXTENSION = ".jpg" private const val HASH_LENGTH = 12 // First 12 chars of SHA-256 - /** - * Generates a deterministic filename for a problem image. Format: - * "problem_{problemId}_{timestamp}_{index}.jpg" - * - * @param problemId The ID of the problem this image belongs to - * @param timestamp ISO8601 timestamp when the image was created - * @param imageIndex The index of this image for the problem (0, 1, 2, etc.) - * @return A consistent filename that will be the same across platforms - */ + /** Generates a deterministic filename for a problem image */ fun generateImageFilename(problemId: String, timestamp: String, imageIndex: Int): String { // Create a deterministic hash from problemId + timestamp + index val input = "${problemId}_${timestamp}_${imageIndex}" @@ -29,25 +21,13 @@ object ImageNamingUtils { return "problem_${hash}_${imageIndex}${IMAGE_EXTENSION}" } - /** - * Generates a deterministic filename for a problem image using current timestamp. - * - * @param problemId The ID of the problem this image belongs to - * @param imageIndex The index of this image for the problem (0, 1, 2, etc.) - * @return A consistent filename - */ + /** Generates a deterministic filename using current timestamp */ fun generateImageFilename(problemId: String, imageIndex: Int): String { val timestamp = DateFormatUtils.nowISO8601() return generateImageFilename(problemId, timestamp, imageIndex) } - /** - * Extracts problem ID from an image filename created by this utility. Returns null if the - * filename doesn't match our naming convention. - * - * @param filename The image filename - * @return The problem ID or null if not a valid filename - */ + /** Extracts problem ID from an image filename */ fun extractProblemIdFromFilename(filename: String): String? { if (!filename.startsWith("problem_") || !filename.endsWith(IMAGE_EXTENSION)) { return null @@ -66,12 +46,7 @@ object ImageNamingUtils { return parts[1] // Return the hash as identifier } - /** - * Validates if a filename follows our naming convention. - * - * @param filename The filename to validate - * @return true if it matches our convention, false otherwise - */ + /** Validates if a filename follows our naming convention */ fun isValidImageFilename(filename: String): Boolean { if (!filename.startsWith("problem_") || !filename.endsWith(IMAGE_EXTENSION)) { return false @@ -86,15 +61,7 @@ object ImageNamingUtils { parts[2].toIntOrNull() != null } - /** - * Migrates an existing UUID-based filename to our naming convention. This is used during sync - * to rename downloaded images. - * - * @param oldFilename The existing filename (UUID-based) - * @param problemId The problem ID this image belongs to - * @param imageIndex The index of this image - * @return The new filename following our convention - */ + /** Migrates an existing filename to our naming convention */ fun migrateFilename(oldFilename: String, problemId: String, imageIndex: Int): String { // If it's already using our convention, keep it if (isValidImageFilename(oldFilename)) { @@ -107,13 +74,7 @@ object ImageNamingUtils { return generateImageFilename(problemId, timestamp, imageIndex) } - /** - * Creates a deterministic hash from input string. Uses SHA-256 and takes first 12 characters - * for filename safety. - * - * @param input The input string to hash - * @return First 12 characters of SHA-256 hash in lowercase - */ + /** Creates a deterministic hash from input string */ private fun createHash(input: String): String { val digest = MessageDigest.getInstance("SHA-256") val hashBytes = digest.digest(input.toByteArray(Charsets.UTF_8)) @@ -121,14 +82,7 @@ object ImageNamingUtils { return hashHex.take(HASH_LENGTH) } - /** - * Batch renames images for a problem to use our naming convention. Returns a mapping of old - * filename -> new filename. - * - * @param problemId The problem ID - * @param existingFilenames List of current image filenames for this problem - * @return Map of old filename to new filename - */ + /** Batch renames images for a problem to use our naming convention */ fun batchRenameForProblem( problemId: String, existingFilenames: List diff --git a/android/app/src/main/java/com/atridad/openclimb/utils/ImageUtils.kt b/android/app/src/main/java/com/atridad/openclimb/utils/ImageUtils.kt index 78fb3cd..47a245d 100644 --- a/android/app/src/main/java/com/atridad/openclimb/utils/ImageUtils.kt +++ b/android/app/src/main/java/com/atridad/openclimb/utils/ImageUtils.kt @@ -16,7 +16,7 @@ object ImageUtils { private const val MAX_IMAGE_SIZE = 1024 private const val IMAGE_QUALITY = 85 - /** Creates the images directory if it doesn't exist */ + // Creates the images directory if it doesn't exist private fun getImagesDirectory(context: Context): File { val imagesDir = File(context.filesDir, IMAGES_DIR) if (!imagesDir.exists()) { @@ -25,14 +25,7 @@ object ImageUtils { return imagesDir } - /** - * Saves an image from a URI with compression and proper orientation - * @param context Android context - * @param imageUri URI of the image to save - * @param problemId The problem ID this image belongs to (optional) - * @param imageIndex The index of this image for the problem (optional) - * @return The relative file path if successful, null otherwise - */ + /** Saves an image from a URI with compression and proper orientation */ fun saveImageFromUri( context: Context, imageUri: Uri, @@ -40,7 +33,7 @@ object ImageUtils { imageIndex: Int? = null ): String? { return try { - // Decode bitmap from a fresh stream to avoid mark/reset dependency + val originalBitmap = context.contentResolver.openInputStream(imageUri)?.use { input -> BitmapFactory.decodeStream(input) @@ -50,7 +43,6 @@ object ImageUtils { val orientedBitmap = correctImageOrientation(context, imageUri, originalBitmap) val compressedBitmap = compressImage(orientedBitmap) - // Generate filename using naming convention if problem info provided val filename = if (problemId != null && imageIndex != null) { ImageNamingUtils.generateImageFilename(problemId, imageIndex) @@ -59,19 +51,16 @@ object ImageUtils { } val imageFile = File(getImagesDirectory(context), filename) - // Save compressed image FileOutputStream(imageFile).use { output -> compressedBitmap.compress(Bitmap.CompressFormat.JPEG, IMAGE_QUALITY, output) } - // Clean up bitmaps originalBitmap.recycle() if (orientedBitmap != originalBitmap) { orientedBitmap.recycle() } compressedBitmap.recycle() - // Return relative path "$IMAGES_DIR/$filename" } catch (e: Exception) { e.printStackTrace() @@ -162,12 +151,7 @@ object ImageUtils { } } - /** - * Gets the full file path for an image - * @param context Android context - * @param relativePath The relative path returned by saveImageFromUri - * @return Full file path - */ + /** Gets the full file path for an image */ fun getImageFile(context: Context, relativePath: String): File { // If relativePath already contains the directory, use it as-is // Otherwise, assume it's just a filename and add the images directory @@ -179,12 +163,7 @@ object ImageUtils { } } - /** - * Deletes an image file - * @param context Android context - * @param relativePath The relative path of the image to delete - * @return true if deleted successfully, false otherwise - */ + /** Deletes an image file */ fun deleteImage(context: Context, relativePath: String): Boolean { return try { val file = getImageFile(context, relativePath) @@ -195,12 +174,7 @@ object ImageUtils { } } - /** - * Imports an image file from the import directory - * @param context Android context - * @param sourceFile The source image file to import - * @return The relative path in app storage, null if failed - */ + /** Imports an image file from the import directory */ fun importImageFile(context: Context, sourceFile: File): String? { return try { if (!sourceFile.exists()) return null @@ -218,11 +192,7 @@ object ImageUtils { } } - /** - * Gets all image files in the images directory - * @param context Android context - * @return List of relative paths for all images - */ + /** Gets all image files in the images directory */ fun getAllImages(context: Context): List { return try { val imagesDir = getImagesDirectory(context) @@ -242,12 +212,7 @@ object ImageUtils { } } - /** - * Saves an image from byte array to app's private storage - * @param context Android context - * @param imageData Byte array of the image data - * @return The relative file path if successful, null otherwise - */ + /** Saves an image from byte array to app's private storage */ fun saveImageFromBytes(context: Context, imageData: ByteArray): String? { return try { val bitmap = BitmapFactory.decodeByteArray(imageData, 0, imageData.size) ?: return null @@ -275,13 +240,7 @@ object ImageUtils { } } - /** - * Saves image data with a specific filename (used for sync to preserve server filenames) - * @param context Android context - * @param imageData The image data as byte array - * @param filename The specific filename to use (including extension) - * @return The relative file path if successful, null otherwise - */ + /** Saves image data with a specific filename */ fun saveImageFromBytesWithFilename( context: Context, imageData: ByteArray, @@ -312,13 +271,7 @@ object ImageUtils { } } - /** - * Migrates existing images to use consistent naming convention - * @param context Android context - * @param problemId The problem ID these images belong to - * @param currentImagePaths List of current image paths for this problem - * @return Map of old path -> new path for successfully migrated images - */ + /** Migrates existing images to use consistent naming convention */ fun migrateImageNaming( context: Context, problemId: String, @@ -349,12 +302,7 @@ object ImageUtils { return migrationMap } - /** - * Batch migrates all images in the system to use consistent naming - * @param context Android context - * @param problemImageMap Map of problem ID -> list of current image paths - * @return Map of old path -> new path for all migrated images - */ + /** Batch migrates all images in the system to use consistent naming */ fun batchMigrateAllImages( context: Context, problemImageMap: Map> @@ -369,11 +317,7 @@ object ImageUtils { return allMigrations } - /** - * Cleans up orphaned images that are not referenced by any problems - * @param context Android context - * @param referencedPaths Set of image paths that are still being used - */ + /** Cleans up orphaned images that are not referenced by any problems */ fun cleanupOrphanedImages(context: Context, referencedPaths: Set) { try { val allImages = getAllImages(context) diff --git a/android/app/src/main/java/com/atridad/openclimb/utils/SessionShareUtils.kt b/android/app/src/main/java/com/atridad/openclimb/utils/SessionShareUtils.kt index fb6748e..711da13 100644 --- a/android/app/src/main/java/com/atridad/openclimb/utils/SessionShareUtils.kt +++ b/android/app/src/main/java/com/atridad/openclimb/utils/SessionShareUtils.kt @@ -481,10 +481,7 @@ object SessionShareUtils { action = Intent.ACTION_SEND type = "image/png" putExtra(Intent.EXTRA_STREAM, uri) - putExtra( - Intent.EXTRA_TEXT, - "Check out my climbing session! ๐Ÿง—โ€โ™€๏ธ #OpenClimb" - ) + putExtra(Intent.EXTRA_TEXT, "Check out my climbing session! #OpenClimb") addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION) } diff --git a/android/app/src/main/java/com/atridad/openclimb/utils/ZipExportImportUtils.kt b/android/app/src/main/java/com/atridad/openclimb/utils/ZipExportImportUtils.kt index a8ee1a6..6456606 100644 --- a/android/app/src/main/java/com/atridad/openclimb/utils/ZipExportImportUtils.kt +++ b/android/app/src/main/java/com/atridad/openclimb/utils/ZipExportImportUtils.kt @@ -20,14 +20,7 @@ object ZipExportImportUtils { private const val IMAGES_DIR_NAME = "images" private const val METADATA_FILENAME = "metadata.txt" - /** - * Creates a ZIP file containing the JSON data and all referenced images - * @param context Android context - * @param exportData The data to export (should be serializable) - * @param referencedImagePaths Set of image paths referenced in the data - * @param directory Optional directory to save to, uses default if null - * @return The created ZIP file - */ + /** Creates a ZIP file containing the JSON data and all referenced images */ fun createExportZip( context: Context, exportData: ClimbDataBackup, @@ -120,13 +113,7 @@ object ZipExportImportUtils { } } - /** - * Creates a ZIP file and writes it to a provided URI - * @param context Android context - * @param uri The URI to write to - * @param exportData The data to export - * @param referencedImagePaths Set of image paths referenced in the data - */ + /** Creates a ZIP file and writes it to a provided URI */ fun createExportZipToUri( context: Context, uri: android.net.Uri, @@ -214,12 +201,7 @@ object ZipExportImportUtils { val importedImagePaths: Map // original filename -> new relative path ) - /** - * Extracts a ZIP file and returns the JSON content and imported image paths - * @param context Android context - * @param zipFile The ZIP file to extract - * @return ImportResult containing the JSON and image path mappings - */ + /** Extracts a ZIP file and returns the JSON content and imported image paths */ fun extractImportZip(context: Context, zipFile: File): ImportResult { var jsonContent = "" val importedImagePaths = mutableMapOf() diff --git a/android/app/src/test/java/com/atridad/openclimb/BusinessLogicTests.kt b/android/app/src/test/java/com/atridad/openclimb/BusinessLogicTests.kt new file mode 100644 index 0000000..c38ac36 --- /dev/null +++ b/android/app/src/test/java/com/atridad/openclimb/BusinessLogicTests.kt @@ -0,0 +1,599 @@ +package com.atridad.openclimb + +import com.atridad.openclimb.data.format.* +import com.atridad.openclimb.data.model.* +import java.time.LocalDateTime +import java.time.format.DateTimeFormatter +import org.junit.Assert.* +import org.junit.Test + +class BusinessLogicTests { + + @Test + fun testClimbSessionLifecycle() { + val gym = createTestGym() + val session = ClimbSession.create(gym.id, "Test session notes") + + assertEquals(gym.id, session.gymId) + assertEquals(SessionStatus.ACTIVE, session.status) + assertNotNull(session.startTime) + assertNull(session.endTime) + assertNull(session.duration) + + val completedSession = + session.copy( + status = SessionStatus.COMPLETED, + endTime = getCurrentTimestamp(), + duration = 7200L + ) + assertEquals(SessionStatus.COMPLETED, completedSession.status) + assertNotNull(completedSession.endTime) + assertNotNull(completedSession.duration) + } + + @Test + fun testAttemptCreationAndValidation() { + val gym = createTestGym() + val problem = createTestProblem(gym.id) + val session = ClimbSession.create(gym.id) + + val attempt = + Attempt.create( + sessionId = session.id, + problemId = problem.id, + result = AttemptResult.SUCCESS, + notes = "Clean send!" + ) + + assertEquals(session.id, attempt.sessionId) + assertEquals(problem.id, attempt.problemId) + assertEquals(AttemptResult.SUCCESS, attempt.result) + assertEquals("Clean send!", attempt.notes) + assertNotNull(attempt.timestamp) + assertNotNull(attempt.createdAt) + } + + @Test + fun testGymProblemRelationship() { + val gym = createTestGym() + val boulderProblem = createTestProblem(gym.id, ClimbType.BOULDER) + val ropeProblem = createTestProblem(gym.id, ClimbType.ROPE) + + // Verify boulder problem uses compatible difficulty system + assertTrue(gym.supportedClimbTypes.contains(boulderProblem.climbType)) + assertTrue(gym.difficultySystems.contains(boulderProblem.difficulty.system)) + + // Verify rope problem uses compatible difficulty system + assertTrue(gym.supportedClimbTypes.contains(ropeProblem.climbType)) + assertTrue(gym.difficultySystems.contains(ropeProblem.difficulty.system)) + } + + @Test + fun testSessionAttemptAggregation() { + val gym = createTestGym() + val session = ClimbSession.create(gym.id) + val problem1 = createTestProblem(gym.id) + val problem2 = createTestProblem(gym.id) + + val attempts = + listOf( + Attempt.create(session.id, problem1.id, AttemptResult.SUCCESS), + Attempt.create(session.id, problem1.id, AttemptResult.FALL), + Attempt.create(session.id, problem2.id, AttemptResult.FLASH), + Attempt.create(session.id, problem2.id, AttemptResult.SUCCESS) + ) + + val sessionStats = calculateSessionStatistics(session, attempts) + + assertEquals(4, sessionStats.totalAttempts) + assertEquals(3, sessionStats.successfulAttempts) + assertEquals(2, sessionStats.uniqueProblems) + assertEquals(75.0, sessionStats.successRate, 0.01) + } + + @Test + fun testDifficultyProgressionTracking() { + val gym = createTestGym() + val session = ClimbSession.create(gym.id) + + val problems = + listOf( + createTestProblemWithGrade(gym.id, "V3"), + createTestProblemWithGrade(gym.id, "V4"), + createTestProblemWithGrade(gym.id, "V5"), + createTestProblemWithGrade(gym.id, "V6") + ) + + val attempts = + problems.map { problem -> + Attempt.create(session.id, problem.id, AttemptResult.SUCCESS) + } + + val progression = calculateDifficultyProgression(attempts, problems) + + assertEquals("V3", progression.minGrade) + assertEquals("V6", progression.maxGrade) + assertEquals(4.5, progression.averageGrade, 0.1) + assertTrue(progression.showsProgression) + } + + @Test + fun testBackupDataIntegrity() { + val gym = createTestGym() + val problems = listOf(createTestProblem(gym.id), createTestProblem(gym.id)) + val session = ClimbSession.create(gym.id) + val attempts = + problems.map { problem -> + Attempt.create(session.id, problem.id, AttemptResult.SUCCESS) + } + + val backup = + createBackupData( + gyms = listOf(gym), + problems = problems, + sessions = listOf(session), + attempts = attempts + ) + + validateBackupIntegrity(backup) + + assertEquals(1, backup.gyms.size) + assertEquals(2, backup.problems.size) + assertEquals(1, backup.sessions.size) + assertEquals(2, backup.attempts.size) + } + + @Test + fun testClimbTypeCompatibilityRules() { + val boulderGym = + Gym( + id = "boulder_gym", + name = "Boulder Gym", + location = "Boulder City", + supportedClimbTypes = listOf(ClimbType.BOULDER), + difficultySystems = listOf(DifficultySystem.V_SCALE, DifficultySystem.FONT), + customDifficultyGrades = emptyList(), + notes = null, + createdAt = getCurrentTimestamp(), + updatedAt = getCurrentTimestamp() + ) + + val ropeGym = + Gym( + id = "rope_gym", + name = "Rope Gym", + location = "Rope City", + supportedClimbTypes = listOf(ClimbType.ROPE), + difficultySystems = listOf(DifficultySystem.YDS), + customDifficultyGrades = emptyList(), + notes = null, + createdAt = getCurrentTimestamp(), + updatedAt = getCurrentTimestamp() + ) + + // Boulder gym should support boulder problems with V-Scale + assertTrue(isCompatibleClimbType(boulderGym, ClimbType.BOULDER, DifficultySystem.V_SCALE)) + assertTrue(isCompatibleClimbType(boulderGym, ClimbType.BOULDER, DifficultySystem.FONT)) + assertFalse(isCompatibleClimbType(boulderGym, ClimbType.ROPE, DifficultySystem.YDS)) + + // Rope gym should support rope problems with YDS + assertTrue(isCompatibleClimbType(ropeGym, ClimbType.ROPE, DifficultySystem.YDS)) + assertFalse(isCompatibleClimbType(ropeGym, ClimbType.BOULDER, DifficultySystem.V_SCALE)) + } + + @Test + fun testSessionDurationCalculation() { + val startTime = "2024-01-01T10:00:00Z" + val endTime = "2024-01-01T12:30:00Z" + + val calculatedDuration = calculateSessionDuration(startTime, endTime) + assertEquals(9000L, calculatedDuration) // 2.5 hours = 9000 seconds + } + + @Test + fun testAttemptSequenceValidation() { + val gym = createTestGym() + val problem = createTestProblem(gym.id) + val session = ClimbSession.create(gym.id) + + val attempts = + listOf( + createAttemptWithTimestamp( + session.id, + problem.id, + "2024-01-01T10:00:00Z", + AttemptResult.FALL + ), + createAttemptWithTimestamp( + session.id, + problem.id, + "2024-01-01T10:05:00Z", + AttemptResult.FALL + ), + createAttemptWithTimestamp( + session.id, + problem.id, + "2024-01-01T10:10:00Z", + AttemptResult.SUCCESS + ) + ) + + val sequence = AttemptSequence(attempts) + + assertEquals(3, sequence.totalAttempts) + assertEquals(2, sequence.failedAttempts) + assertEquals(1, sequence.successfulAttempts) + assertTrue(sequence.isValidSequence()) + assertEquals(AttemptResult.SUCCESS, sequence.finalResult) + } + + @Test + fun testGradeConsistencyValidation() { + val validCombinations = + listOf( + Pair(ClimbType.BOULDER, DifficultySystem.V_SCALE), + Pair(ClimbType.BOULDER, DifficultySystem.FONT), + Pair(ClimbType.ROPE, DifficultySystem.YDS), + Pair(ClimbType.BOULDER, DifficultySystem.CUSTOM), + Pair(ClimbType.ROPE, DifficultySystem.CUSTOM) + ) + + val invalidCombinations = + listOf( + Pair(ClimbType.BOULDER, DifficultySystem.YDS), + Pair(ClimbType.ROPE, DifficultySystem.V_SCALE), + Pair(ClimbType.ROPE, DifficultySystem.FONT) + ) + + validCombinations.forEach { (climbType, difficultySystem) -> + assertTrue( + "$climbType should be compatible with $difficultySystem", + isValidGradeCombination(climbType, difficultySystem) + ) + } + + invalidCombinations.forEach { (climbType, difficultySystem) -> + assertFalse( + "$climbType should not be compatible with $difficultySystem", + isValidGradeCombination(climbType, difficultySystem) + ) + } + } + + @Test + fun testProblemTagNormalization() { + val rawTags = listOf("OVERHANG", "crimpy", " Technical ", "DYNAMIC", "") + val normalizedTags = normalizeTags(rawTags) + + assertEquals(4, normalizedTags.size) + assertTrue(normalizedTags.contains("overhang")) + assertTrue(normalizedTags.contains("crimpy")) + assertTrue(normalizedTags.contains("technical")) + assertTrue(normalizedTags.contains("dynamic")) + assertFalse(normalizedTags.contains("")) + } + + @Test + fun testImagePathHandling() { + val originalPaths = + listOf( + "/storage/images/problem1.jpg", + "/data/cache/problem2.png", + "relative/path/problem3.jpeg" + ) + + val relativePaths = convertToRelativePaths(originalPaths) + + assertEquals(3, relativePaths.size) + assertTrue(relativePaths.all { !it.startsWith("/") }) + assertTrue(relativePaths.contains("problem1.jpg")) + assertTrue(relativePaths.contains("problem2.png")) + assertTrue(relativePaths.contains("problem3.jpeg")) + } + + // Helper functions and data classes + + private fun createTestGym(): Gym { + return Gym( + id = "test_gym_1", + name = "Test Climbing Gym", + location = "Test City", + supportedClimbTypes = listOf(ClimbType.BOULDER, ClimbType.ROPE), + difficultySystems = listOf(DifficultySystem.V_SCALE, DifficultySystem.YDS), + customDifficultyGrades = emptyList(), + notes = "Test gym for unit testing", + createdAt = getCurrentTimestamp(), + updatedAt = getCurrentTimestamp() + ) + } + + private fun createTestProblem( + gymId: String, + climbType: ClimbType = ClimbType.BOULDER + ): Problem { + val difficulty = + when (climbType) { + ClimbType.BOULDER -> DifficultyGrade(DifficultySystem.V_SCALE, "V5") + ClimbType.ROPE -> DifficultyGrade(DifficultySystem.YDS, "5.10a") + } + + return Problem( + id = "test_problem_${java.util.UUID.randomUUID()}", + gymId = gymId, + name = "Test Problem", + description = "A test climbing problem", + climbType = climbType, + difficulty = difficulty, + tags = listOf("test", "overhang"), + location = "Wall A", + imagePaths = emptyList(), + isActive = true, + dateSet = "2024-01-01", + notes = null, + createdAt = getCurrentTimestamp(), + updatedAt = getCurrentTimestamp() + ) + } + + private fun createTestProblemWithGrade(gymId: String, grade: String): Problem { + return Problem( + id = "test_problem_${java.util.UUID.randomUUID()}", + gymId = gymId, + name = "Test Problem $grade", + description = null, + climbType = ClimbType.BOULDER, + difficulty = DifficultyGrade(DifficultySystem.V_SCALE, grade), + tags = emptyList(), + location = null, + imagePaths = emptyList(), + isActive = true, + dateSet = null, + notes = null, + createdAt = getCurrentTimestamp(), + updatedAt = getCurrentTimestamp() + ) + } + + private fun createAttemptWithTimestamp( + sessionId: String, + problemId: String, + timestamp: String, + result: AttemptResult + ): Attempt { + return Attempt.create( + sessionId = sessionId, + problemId = problemId, + result = result, + timestamp = timestamp + ) + } + + private fun getCurrentTimestamp(): String { + return LocalDateTime.now().format(DateTimeFormatter.ISO_LOCAL_DATE_TIME) + "Z" + } + + private fun calculateSessionStatistics( + session: ClimbSession, + attempts: List + ): SessionStatistics { + val successful = + attempts.count { + it.result == AttemptResult.SUCCESS || it.result == AttemptResult.FLASH + } + val uniqueProblems = attempts.map { it.problemId }.toSet().size + val successRate = (successful.toDouble() / attempts.size) * 100 + + return SessionStatistics( + totalAttempts = attempts.size, + successfulAttempts = successful, + uniqueProblems = uniqueProblems, + successRate = successRate + ) + } + + private fun calculateDifficultyProgression( + attempts: List, + problems: List + ): DifficultyProgression { + val problemMap = problems.associateBy { it.id } + val grades = + attempts + .mapNotNull { attempt -> problemMap[attempt.problemId]?.difficulty?.grade } + .filter { it.startsWith("V") } + + val numericGrades = + grades.mapNotNull { grade -> + when (grade) { + "VB" -> 0 + else -> grade.removePrefix("V").toIntOrNull() + } + } + + val minGrade = "V${numericGrades.minOrNull() ?: 0}".replace("V0", "VB") + val maxGrade = "V${numericGrades.maxOrNull() ?: 0}".replace("V0", "VB") + val avgGrade = numericGrades.average() + val showsProgression = + numericGrades.size > 1 && + (numericGrades.maxOrNull() ?: 0) > (numericGrades.minOrNull() ?: 0) + + return DifficultyProgression(minGrade, maxGrade, avgGrade, showsProgression) + } + + private fun createBackupData( + gyms: List, + problems: List, + sessions: List, + attempts: List + ): ClimbDataBackup { + return ClimbDataBackup( + exportedAt = getCurrentTimestamp(), + version = "2.0", + formatVersion = "2.0", + gyms = + gyms.map { gym -> + BackupGym( + id = gym.id, + name = gym.name, + location = gym.location, + supportedClimbTypes = gym.supportedClimbTypes, + difficultySystems = gym.difficultySystems, + customDifficultyGrades = gym.customDifficultyGrades, + notes = gym.notes, + createdAt = gym.createdAt, + updatedAt = gym.updatedAt + ) + }, + problems = + problems.map { problem -> + BackupProblem( + id = problem.id, + gymId = problem.gymId, + name = problem.name, + description = problem.description, + climbType = problem.climbType, + difficulty = problem.difficulty, + tags = problem.tags, + location = problem.location, + imagePaths = problem.imagePaths, + isActive = problem.isActive, + dateSet = problem.dateSet, + notes = problem.notes, + createdAt = problem.createdAt, + updatedAt = problem.updatedAt + ) + }, + sessions = + sessions.map { session -> + BackupClimbSession( + id = session.id, + gymId = session.gymId, + date = session.date, + startTime = session.startTime, + endTime = session.endTime, + duration = session.duration, + status = session.status, + notes = session.notes, + createdAt = session.createdAt, + updatedAt = session.updatedAt + ) + }, + attempts = + attempts.map { attempt -> + BackupAttempt( + id = attempt.id, + sessionId = attempt.sessionId, + problemId = attempt.problemId, + result = attempt.result, + highestHold = attempt.highestHold, + notes = attempt.notes, + duration = attempt.duration, + restTime = attempt.restTime, + timestamp = attempt.timestamp, + createdAt = attempt.createdAt + ) + } + ) + } + + private fun validateBackupIntegrity(backup: ClimbDataBackup) { + // Verify all gym references exist + val gymIds = backup.gyms.map { it.id }.toSet() + backup.problems.forEach { problem -> + assertTrue( + "Problem ${problem.id} references non-existent gym ${problem.gymId}", + gymIds.contains(problem.gymId) + ) + } + + // Verify all session references exist + val sessionIds = backup.sessions.map { it.id }.toSet() + backup.attempts.forEach { attempt -> + assertTrue( + "Attempt ${attempt.id} references non-existent session ${attempt.sessionId}", + sessionIds.contains(attempt.sessionId) + ) + } + + // Verify all problem references exist + val problemIds = backup.problems.map { it.id }.toSet() + backup.attempts.forEach { attempt -> + assertTrue( + "Attempt ${attempt.id} references non-existent problem ${attempt.problemId}", + problemIds.contains(attempt.problemId) + ) + } + } + + private fun isCompatibleClimbType( + gym: Gym, + climbType: ClimbType, + difficultySystem: DifficultySystem + ): Boolean { + return gym.supportedClimbTypes.contains(climbType) && + gym.difficultySystems.contains(difficultySystem) + } + + private fun calculateSessionDuration(startTime: String, endTime: String): Long { + // Simplified duration calculation (in seconds) + // In real implementation, would use proper date parsing + return 9000L // 2.5 hours for test + } + + private fun isValidGradeCombination( + climbType: ClimbType, + difficultySystem: DifficultySystem + ): Boolean { + return when (climbType) { + ClimbType.BOULDER -> + difficultySystem in + listOf( + DifficultySystem.V_SCALE, + DifficultySystem.FONT, + DifficultySystem.CUSTOM + ) + ClimbType.ROPE -> + difficultySystem in listOf(DifficultySystem.YDS, DifficultySystem.CUSTOM) + } + } + + private fun normalizeTags(tags: List): List { + return tags.map { it.trim().lowercase() }.filter { it.isNotEmpty() } + } + + private fun convertToRelativePaths(paths: List): List { + return paths.map { path -> path.substringAfterLast('/') } + } + + // Data classes for testing + + data class SessionStatistics( + val totalAttempts: Int, + val successfulAttempts: Int, + val uniqueProblems: Int, + val successRate: Double + ) + + data class DifficultyProgression( + val minGrade: String, + val maxGrade: String, + val averageGrade: Double, + val showsProgression: Boolean + ) + + data class AttemptSequence(val attempts: List) { + val totalAttempts = attempts.size + val failedAttempts = + attempts.count { + it.result == AttemptResult.FALL || it.result == AttemptResult.NO_PROGRESS + } + val successfulAttempts = + attempts.count { + it.result == AttemptResult.SUCCESS || it.result == AttemptResult.FLASH + } + val finalResult = attempts.lastOrNull()?.result + + fun isValidSequence(): Boolean { + return attempts.isNotEmpty() && attempts.all { it.timestamp.isNotEmpty() } + } + } +} diff --git a/android/app/src/test/java/com/atridad/openclimb/DataModelTests.kt b/android/app/src/test/java/com/atridad/openclimb/DataModelTests.kt new file mode 100644 index 0000000..8e70402 --- /dev/null +++ b/android/app/src/test/java/com/atridad/openclimb/DataModelTests.kt @@ -0,0 +1,571 @@ +package com.atridad.openclimb + +import com.atridad.openclimb.data.format.* +import com.atridad.openclimb.data.model.* +import java.time.Instant +import java.time.format.DateTimeFormatter +import org.junit.Assert.* +import org.junit.Test + +class DataModelTests { + + @Test + fun testClimbTypeEnumValues() { + val expectedTypes = setOf("ROPE", "BOULDER") + val actualTypes = ClimbType.entries.map { it.name }.toSet() + assertEquals(expectedTypes, actualTypes) + } + + @Test + fun testClimbTypeDisplayNames() { + assertEquals("Rope", ClimbType.ROPE.getDisplayName()) + assertEquals("Bouldering", ClimbType.BOULDER.getDisplayName()) + } + + @Test + fun testDifficultySystemEnumValues() { + val systems = DifficultySystem.entries + assertTrue(systems.contains(DifficultySystem.V_SCALE)) + assertTrue(systems.contains(DifficultySystem.YDS)) + assertTrue(systems.contains(DifficultySystem.FONT)) + assertTrue(systems.contains(DifficultySystem.CUSTOM)) + assertEquals(4, systems.size) + } + + @Test + fun testDifficultySystemDisplayNames() { + assertEquals("V Scale", DifficultySystem.V_SCALE.getDisplayName()) + assertEquals("YDS (Yosemite)", DifficultySystem.YDS.getDisplayName()) + assertEquals("Font Scale", DifficultySystem.FONT.getDisplayName()) + assertEquals("Custom", DifficultySystem.CUSTOM.getDisplayName()) + } + + @Test + fun testDifficultySystemClimbTypeCompatibility() { + // Test bouldering systems + assertTrue(DifficultySystem.V_SCALE.isBoulderingSystem()) + assertTrue(DifficultySystem.FONT.isBoulderingSystem()) + assertFalse(DifficultySystem.YDS.isBoulderingSystem()) + assertTrue(DifficultySystem.CUSTOM.isBoulderingSystem()) + + // Test rope systems + assertTrue(DifficultySystem.YDS.isRopeSystem()) + assertFalse(DifficultySystem.V_SCALE.isRopeSystem()) + assertFalse(DifficultySystem.FONT.isRopeSystem()) + assertTrue(DifficultySystem.CUSTOM.isRopeSystem()) + } + + @Test + fun testDifficultySystemAvailableGrades() { + val vScaleGrades = DifficultySystem.V_SCALE.getAvailableGrades() + assertTrue(vScaleGrades.contains("VB")) + assertTrue(vScaleGrades.contains("V0")) + assertTrue(vScaleGrades.contains("V17")) + assertEquals("VB", vScaleGrades.first()) + + val ydsGrades = DifficultySystem.YDS.getAvailableGrades() + assertTrue(ydsGrades.contains("5.0")) + assertTrue(ydsGrades.contains("5.15d")) + assertTrue(ydsGrades.contains("5.10a")) + + val fontGrades = DifficultySystem.FONT.getAvailableGrades() + assertTrue(fontGrades.contains("3")) + assertTrue(fontGrades.contains("8C+")) + assertTrue(fontGrades.contains("6A")) + + val customGrades = DifficultySystem.CUSTOM.getAvailableGrades() + assertTrue(customGrades.isEmpty()) + } + + @Test + fun testDifficultySystemsForClimbType() { + val boulderSystems = DifficultySystem.getSystemsForClimbType(ClimbType.BOULDER) + assertTrue(boulderSystems.contains(DifficultySystem.V_SCALE)) + assertTrue(boulderSystems.contains(DifficultySystem.FONT)) + assertTrue(boulderSystems.contains(DifficultySystem.CUSTOM)) + assertFalse(boulderSystems.contains(DifficultySystem.YDS)) + + val ropeSystems = DifficultySystem.getSystemsForClimbType(ClimbType.ROPE) + assertTrue(ropeSystems.contains(DifficultySystem.YDS)) + assertTrue(ropeSystems.contains(DifficultySystem.CUSTOM)) + assertFalse(ropeSystems.contains(DifficultySystem.V_SCALE)) + assertFalse(ropeSystems.contains(DifficultySystem.FONT)) + } + + @Test + fun testDifficultyGradeCreation() { + val grade = DifficultyGrade(DifficultySystem.V_SCALE, "V5") + assertEquals(DifficultySystem.V_SCALE, grade.system) + assertEquals("V5", grade.grade) + assertEquals(5, grade.numericValue) + } + + @Test + fun testDifficultyGradeNumericValueCalculation() { + val vbGrade = DifficultyGrade(DifficultySystem.V_SCALE, "VB") + assertEquals(0, vbGrade.numericValue) + + val v5Grade = DifficultyGrade(DifficultySystem.V_SCALE, "V5") + assertEquals(5, v5Grade.numericValue) + + val ydsGrade = DifficultyGrade(DifficultySystem.YDS, "5.9") + assertTrue(ydsGrade.numericValue > 0) + } + + @Test + fun testDifficultyGradeComparison() { + val v3 = DifficultyGrade(DifficultySystem.V_SCALE, "V3") + val v5 = DifficultyGrade(DifficultySystem.V_SCALE, "V5") + val vb = DifficultyGrade(DifficultySystem.V_SCALE, "VB") + + assertTrue(v3.compareTo(v5) < 0) // V3 is easier than V5 + assertTrue(v5.compareTo(v3) > 0) // V5 is harder than V3 + assertTrue(vb.compareTo(v3) < 0) // VB is easier than V3 + assertEquals(0, v3.compareTo(v3)) // Same grade + } + + @Test + fun testAttemptResultEnumValues() { + val expectedResults = setOf("SUCCESS", "FALL", "NO_PROGRESS", "FLASH") + val actualResults = AttemptResult.entries.map { it.name }.toSet() + assertEquals(expectedResults, actualResults) + } + + @Test + fun testSessionStatusEnumValues() { + val expectedStatuses = setOf("ACTIVE", "COMPLETED", "PAUSED") + val actualStatuses = SessionStatus.entries.map { it.name }.toSet() + assertEquals(expectedStatuses, actualStatuses) + } + + @Test + fun testBackupGymCreationAndValidation() { + val gym = + BackupGym( + id = "gym123", + name = "Test Climbing Gym", + location = "Test City", + supportedClimbTypes = listOf(ClimbType.BOULDER, ClimbType.ROPE), + difficultySystems = listOf(DifficultySystem.V_SCALE, DifficultySystem.YDS), + customDifficultyGrades = emptyList(), + notes = "Great gym for beginners", + createdAt = "2024-01-01T10:00:00Z", + updatedAt = "2024-01-01T10:00:00Z" + ) + + assertEquals("gym123", gym.id) + assertEquals("Test Climbing Gym", gym.name) + assertEquals("Test City", gym.location) + assertEquals(2, gym.supportedClimbTypes.size) + assertTrue(gym.supportedClimbTypes.contains(ClimbType.BOULDER)) + assertTrue(gym.supportedClimbTypes.contains(ClimbType.ROPE)) + assertEquals(2, gym.difficultySystems.size) + assertTrue(gym.difficultySystems.contains(DifficultySystem.V_SCALE)) + assertTrue(gym.difficultySystems.contains(DifficultySystem.YDS)) + } + + @Test + fun testBackupProblemCreationAndValidation() { + val problem = + BackupProblem( + id = "problem123", + gymId = "gym123", + name = "Test Problem", + description = "A challenging boulder problem", + climbType = ClimbType.BOULDER, + difficulty = DifficultyGrade(DifficultySystem.V_SCALE, "V5"), + tags = listOf("overhang", "crimpy"), + location = "Wall A", + imagePaths = listOf("image1.jpg", "image2.jpg"), + isActive = true, + dateSet = "2024-01-01", + notes = "Watch the start holds", + createdAt = "2024-01-01T10:00:00Z", + updatedAt = "2024-01-01T10:00:00Z" + ) + + assertEquals("problem123", problem.id) + assertEquals("gym123", problem.gymId) + assertEquals("Test Problem", problem.name) + assertEquals(ClimbType.BOULDER, problem.climbType) + assertEquals("V5", problem.difficulty.grade) + assertTrue(problem.isActive) + assertEquals(2, problem.tags.size) + assertEquals(2, problem.imagePaths?.size ?: 0) + } + + @Test + fun testBackupClimbSessionCreationAndValidation() { + val session = + BackupClimbSession( + id = "session123", + gymId = "gym123", + date = "2024-01-01", + startTime = "2024-01-01T10:00:00Z", + endTime = "2024-01-01T12:00:00Z", + duration = 7200, + status = SessionStatus.COMPLETED, + notes = "Great session today", + createdAt = "2024-01-01T10:00:00Z", + updatedAt = "2024-01-01T12:00:00Z" + ) + + assertEquals("session123", session.id) + assertEquals("gym123", session.gymId) + assertEquals("2024-01-01", session.date) + assertEquals(SessionStatus.COMPLETED, session.status) + assertEquals(7200L, session.duration) + } + + @Test + fun testBackupAttemptCreationAndValidation() { + val attempt = + BackupAttempt( + id = "attempt123", + sessionId = "session123", + problemId = "problem123", + result = AttemptResult.SUCCESS, + highestHold = "Top", + notes = "Stuck it on second try", + duration = 300, + restTime = 120, + timestamp = "2024-01-01T10:30:00Z", + createdAt = "2024-01-01T10:30:00Z" + ) + + assertEquals("attempt123", attempt.id) + assertEquals("session123", attempt.sessionId) + assertEquals("problem123", attempt.problemId) + assertEquals(AttemptResult.SUCCESS, attempt.result) + assertEquals("Top", attempt.highestHold) + assertEquals(300L, attempt.duration) + assertEquals(120L, attempt.restTime) + } + + @Test + fun testClimbDataBackupCreationAndValidation() { + val backup = + ClimbDataBackup( + exportedAt = "2024-01-01T10:00:00Z", + version = "2.0", + formatVersion = "2.0", + gyms = emptyList(), + problems = emptyList(), + sessions = emptyList(), + attempts = emptyList() + ) + + assertEquals("2.0", backup.version) + assertEquals("2.0", backup.formatVersion) + assertTrue(backup.gyms.isEmpty()) + assertTrue(backup.problems.isEmpty()) + assertTrue(backup.sessions.isEmpty()) + assertTrue(backup.attempts.isEmpty()) + } + + @Test + fun testDateFormatValidation() { + val validDate = "2024-01-01T10:00:00Z" + val formatter = DateTimeFormatter.ISO_INSTANT + + try { + val instant = Instant.from(formatter.parse(validDate)) + assertNotNull(instant) + } catch (e: Exception) { + fail("Should not throw exception for valid date: $e") + } + } + + @Test + fun testSessionDurationCalculation() { + val session = + BackupClimbSession( + id = "test", + gymId = "gym1", + date = "2024-01-01", + startTime = "2024-01-01T10:00:00Z", + endTime = "2024-01-01T12:00:00Z", + duration = 7200, + status = SessionStatus.COMPLETED, + notes = null, + createdAt = "2024-01-01T10:00:00Z", + updatedAt = "2024-01-01T12:00:00Z" + ) + + assertEquals(7200L, session.duration) + val hours = session.duration!! / 3600 + assertEquals(2L, hours) + } + + @Test + fun testEmptyCollectionsHandling() { + val gym = + BackupGym( + id = "gym1", + name = "Test Gym", + location = null, + supportedClimbTypes = emptyList(), + difficultySystems = emptyList(), + customDifficultyGrades = emptyList(), + notes = null, + createdAt = "2024-01-01T10:00:00Z", + updatedAt = "2024-01-01T10:00:00Z" + ) + + assertTrue(gym.supportedClimbTypes.isEmpty()) + assertTrue(gym.difficultySystems.isEmpty()) + assertTrue(gym.customDifficultyGrades.isEmpty()) + assertNull(gym.location) + assertNull(gym.notes) + } + + @Test + fun testNullableFieldsHandling() { + val problem = + BackupProblem( + id = "problem1", + gymId = "gym1", + name = null, + description = null, + climbType = ClimbType.BOULDER, + difficulty = DifficultyGrade(DifficultySystem.V_SCALE, "V1"), + tags = emptyList(), + location = null, + imagePaths = null, + isActive = true, + dateSet = null, + notes = null, + createdAt = "2024-01-01T10:00:00Z", + updatedAt = "2024-01-01T10:00:00Z" + ) + + assertNull(problem.name) + assertNull(problem.description) + assertNull(problem.location) + assertNull(problem.dateSet) + assertNull(problem.notes) + assertTrue(problem.tags.isEmpty()) + assertNull(problem.imagePaths) + } + + @Test + fun testUniqueIdGeneration() { + val id1 = java.util.UUID.randomUUID().toString() + val id2 = java.util.UUID.randomUUID().toString() + + assertNotEquals(id1, id2) + assertEquals(36, id1.length) + assertTrue(id1.contains("-")) + } + + @Test + fun testBackupDataFormatValidation() { + val testJson = + """ + { + "exportedAt": "2024-01-01T10:00:00Z", + "version": "2.0", + "formatVersion": "2.0", + "gyms": [], + "problems": [], + "sessions": [], + "attempts": [] + } + """.trimIndent() + + assertTrue(testJson.contains("exportedAt")) + assertTrue(testJson.contains("version")) + assertTrue(testJson.contains("gyms")) + assertTrue(testJson.contains("problems")) + assertTrue(testJson.contains("sessions")) + assertTrue(testJson.contains("attempts")) + } + + @Test + fun testDateTimeFormatting() { + val currentTime = System.currentTimeMillis() + assertTrue(currentTime > 0) + + val timeString = java.time.Instant.ofEpochMilli(currentTime).toString() + assertTrue(timeString.isNotEmpty()) + assertTrue(timeString.contains("T")) + assertTrue(timeString.endsWith("Z")) + } + + @Test + fun testClimbTypeAndDifficultySystemCompatibility() { + // Test that V_SCALE works with BOULDER + val boulderProblem = + BackupProblem( + id = "boulder1", + gymId = "gym1", + name = "Boulder Problem", + description = null, + climbType = ClimbType.BOULDER, + difficulty = DifficultyGrade(DifficultySystem.V_SCALE, "V3"), + tags = emptyList(), + location = null, + imagePaths = null, + isActive = true, + dateSet = null, + notes = null, + createdAt = "2024-01-01T10:00:00Z", + updatedAt = "2024-01-01T10:00:00Z" + ) + + assertEquals(ClimbType.BOULDER, boulderProblem.climbType) + assertEquals(DifficultySystem.V_SCALE, boulderProblem.difficulty.system) + + // Test that YDS works with ROPE + val ropeProblem = + BackupProblem( + id = "rope1", + gymId = "gym1", + name = "Rope Problem", + description = null, + climbType = ClimbType.ROPE, + difficulty = DifficultyGrade(DifficultySystem.YDS, "5.10a"), + tags = emptyList(), + location = null, + imagePaths = null, + isActive = true, + dateSet = null, + notes = null, + createdAt = "2024-01-01T10:00:00Z", + updatedAt = "2024-01-01T10:00:00Z" + ) + + assertEquals(ClimbType.ROPE, ropeProblem.climbType) + assertEquals(DifficultySystem.YDS, ropeProblem.difficulty.system) + } + + @Test + fun testStringOperations() { + val problemName = " Test Problem V5 " + val trimmedName = problemName.trim() + val uppercaseName = trimmedName.uppercase() + val lowercaseName = trimmedName.lowercase() + + assertEquals("Test Problem V5", trimmedName) + assertEquals("TEST PROBLEM V5", uppercaseName) + assertEquals("test problem v5", lowercaseName) + + val components = trimmedName.split(" ") + assertEquals(3, components.size) + assertEquals("V5", components.last()) + } + + @Test + fun testNumericOperations() { + val grades = listOf(3, 5, 7, 4, 6) + val sum = grades.sum() + val average = grades.average() + val maxGrade = grades.maxOrNull() ?: 0 + val minGrade = grades.minOrNull() ?: 0 + + assertEquals(25, sum) + assertEquals(5.0, average, 0.01) + assertEquals(7, maxGrade) + assertEquals(3, minGrade) + } + + @Test + fun testAttemptResultValidation() { + val validResults = + listOf( + AttemptResult.SUCCESS, + AttemptResult.FALL, + AttemptResult.NO_PROGRESS, + AttemptResult.FLASH + ) + + assertEquals(4, validResults.size) + assertTrue(validResults.contains(AttemptResult.SUCCESS)) + assertTrue(validResults.contains(AttemptResult.FALL)) + assertTrue(validResults.contains(AttemptResult.NO_PROGRESS)) + assertTrue(validResults.contains(AttemptResult.FLASH)) + } + + @Test + fun testSessionStatusValidation() { + val validStatuses = + listOf(SessionStatus.ACTIVE, SessionStatus.COMPLETED, SessionStatus.PAUSED) + + assertEquals(3, validStatuses.size) + assertTrue(validStatuses.contains(SessionStatus.ACTIVE)) + assertTrue(validStatuses.contains(SessionStatus.COMPLETED)) + assertTrue(validStatuses.contains(SessionStatus.PAUSED)) + } + + @Test + fun testClimbDataIntegrity() { + val gym = + BackupGym( + id = "gym1", + name = "Test Gym", + location = "Test City", + supportedClimbTypes = listOf(ClimbType.BOULDER), + difficultySystems = listOf(DifficultySystem.V_SCALE), + customDifficultyGrades = emptyList(), + notes = null, + createdAt = "2024-01-01T10:00:00Z", + updatedAt = "2024-01-01T10:00:00Z" + ) + + val problem = + BackupProblem( + id = "problem1", + gymId = gym.id, + name = "Test Problem", + description = null, + climbType = ClimbType.BOULDER, + difficulty = DifficultyGrade(DifficultySystem.V_SCALE, "V3"), + tags = emptyList(), + location = null, + imagePaths = null, + isActive = true, + dateSet = null, + notes = null, + createdAt = "2024-01-01T10:00:00Z", + updatedAt = "2024-01-01T10:00:00Z" + ) + + val session = + BackupClimbSession( + id = "session1", + gymId = gym.id, + date = "2024-01-01", + startTime = "2024-01-01T10:00:00Z", + endTime = "2024-01-01T11:00:00Z", + duration = 3600, + status = SessionStatus.COMPLETED, + notes = null, + createdAt = "2024-01-01T10:00:00Z", + updatedAt = "2024-01-01T11:00:00Z" + ) + + val attempt = + BackupAttempt( + id = "attempt1", + sessionId = session.id, + problemId = problem.id, + result = AttemptResult.SUCCESS, + highestHold = null, + notes = null, + duration = 120, + restTime = null, + timestamp = "2024-01-01T10:30:00Z", + createdAt = "2024-01-01T10:30:00Z" + ) + + // Verify referential integrity + assertEquals(gym.id, problem.gymId) + assertEquals(gym.id, session.gymId) + assertEquals(session.id, attempt.sessionId) + assertEquals(problem.id, attempt.problemId) + + // Verify climb type compatibility + assertTrue(gym.supportedClimbTypes.contains(problem.climbType)) + assertTrue(gym.difficultySystems.contains(problem.difficulty.system)) + } +} diff --git a/android/app/src/test/java/com/atridad/openclimb/ExampleUnitTest.kt b/android/app/src/test/java/com/atridad/openclimb/ExampleUnitTest.kt deleted file mode 100644 index 39188ef..0000000 --- a/android/app/src/test/java/com/atridad/openclimb/ExampleUnitTest.kt +++ /dev/null @@ -1,17 +0,0 @@ -package com.atridad.openclimb - -import org.junit.Test - -import org.junit.Assert.* - -/** - * Example local unit test, which will execute on the development machine (host). - * - * See [testing documentation](http://d.android.com/tools/testing). - */ -class ExampleUnitTest { - @Test - fun addition_isCorrect() { - assertEquals(4, 2 + 2) - } -} \ No newline at end of file diff --git a/android/app/src/test/java/com/atridad/openclimb/SyncMergeLogicTest.kt b/android/app/src/test/java/com/atridad/openclimb/SyncMergeLogicTest.kt index fd687c9..eff31fb 100644 --- a/android/app/src/test/java/com/atridad/openclimb/SyncMergeLogicTest.kt +++ b/android/app/src/test/java/com/atridad/openclimb/SyncMergeLogicTest.kt @@ -67,7 +67,7 @@ class SyncMergeLogicTest { id = "attempt1", sessionId = "session1", problemId = "problem1", - result = AttemptResult.COMPLETED, + result = AttemptResult.SUCCESS, highestHold = null, notes = null, duration = 300, @@ -96,7 +96,7 @@ class SyncMergeLogicTest { id = "gym1", name = "Updated Gym 1", location = "Updated Location", - supportedClimbTypes = listOf(ClimbType.BOULDER, ClimbType.SPORT), + supportedClimbTypes = listOf(ClimbType.BOULDER, ClimbType.ROPE), difficultySystems = listOf(DifficultySystem.V_SCALE, DifficultySystem.YDS), customDifficultyGrades = emptyList(), @@ -109,7 +109,7 @@ class SyncMergeLogicTest { id = "gym2", name = "Server Gym 2", location = "Server Location", - supportedClimbTypes = listOf(ClimbType.TRAD), + supportedClimbTypes = listOf(ClimbType.ROPE), difficultySystems = listOf(DifficultySystem.YDS), customDifficultyGrades = emptyList(), notes = null, @@ -143,7 +143,7 @@ class SyncMergeLogicTest { gymId = "gym2", name = "Server Problem", description = "Server description", - climbType = ClimbType.TRAD, + climbType = ClimbType.ROPE, difficulty = DifficultyGrade(DifficultySystem.YDS, "5.10a"), tags = listOf("server"), location = null, @@ -180,7 +180,7 @@ class SyncMergeLogicTest { id = "attempt2", sessionId = "session2", problemId = "problem2", - result = AttemptResult.FELL, + result = AttemptResult.FALL, highestHold = "Last move", notes = "Almost had it", duration = 180, diff --git a/android/app/src/test/java/com/atridad/openclimb/UtilityTests.kt b/android/app/src/test/java/com/atridad/openclimb/UtilityTests.kt new file mode 100644 index 0000000..58cc1f4 --- /dev/null +++ b/android/app/src/test/java/com/atridad/openclimb/UtilityTests.kt @@ -0,0 +1,370 @@ +package com.atridad.openclimb + +import java.time.LocalDateTime +import java.time.format.DateTimeFormatter +import java.util.concurrent.TimeUnit +import org.junit.Assert.* +import org.junit.Test + +class UtilityTests { + + @Test + fun testDateTimeUtilities() { + val now = System.currentTimeMillis() + val dateTime = LocalDateTime.now() + + assertTrue(now > 0) + assertNotNull(dateTime) + + val formatted = dateTime.format(DateTimeFormatter.ISO_LOCAL_DATE_TIME) + assertFalse(formatted.isEmpty()) + assertTrue(formatted.contains("T")) + } + + @Test + fun testDurationCalculations() { + val startTime = 1000L + val endTime = 4000L + val duration = endTime - startTime + + assertEquals(3000L, duration) + + val minutes = TimeUnit.MILLISECONDS.toMinutes(duration) + val seconds = TimeUnit.MILLISECONDS.toSeconds(duration) + + assertEquals(0L, minutes) + assertEquals(3L, seconds) + } + + @Test + fun testStringValidation() { + val validName = "Test Gym" + val emptyName = "" + val whitespaceName = " " + val nullName: String? = null + + assertTrue(isValidString(validName)) + assertFalse(isValidString(emptyName)) + assertFalse(isValidString(whitespaceName)) + assertFalse(isValidString(nullName)) + } + + @Test + fun testGradeConversion() { + val vGrade = "V5" + val ydsGrade = "5.10a" + val fontGrade = "6A" + + assertTrue(isValidVGrade(vGrade)) + assertTrue(isValidYDSGrade(ydsGrade)) + assertTrue(isValidFontGrade(fontGrade)) + + assertFalse(isValidVGrade("Invalid")) + assertFalse(isValidYDSGrade("Invalid")) + assertFalse(isValidFontGrade("Invalid")) + } + + @Test + fun testNumericGradeExtraction() { + assertEquals(0, extractVGradeNumber("VB")) + assertEquals(5, extractVGradeNumber("V5")) + assertEquals(12, extractVGradeNumber("V12")) + assertEquals(-1, extractVGradeNumber("Invalid")) + } + + @Test + fun testClimbingStatistics() { + val attempts = + listOf( + AttemptData("SUCCESS", 120), + AttemptData("FALL", 90), + AttemptData("SUCCESS", 150), + AttemptData("FLASH", 60), + AttemptData("FALL", 110) + ) + + val stats = calculateAttemptStatistics(attempts) + + assertEquals(5, stats.totalAttempts) + assertEquals(3, stats.successfulAttempts) + assertEquals(60.0, stats.successRate, 0.01) + assertEquals(106.0, stats.averageDuration, 0.01) + } + + @Test + fun testSessionDurationFormatting() { + assertEquals("0m", formatDuration(0)) + assertEquals("1m", formatDuration(60)) + assertEquals("1h 30m", formatDuration(5400)) + assertEquals("2h", formatDuration(7200)) + assertEquals("2h 5m", formatDuration(7500)) + } + + @Test + fun testDifficultyComparison() { + assertTrue(compareVGrades("V3", "V5") < 0) + assertTrue(compareVGrades("V5", "V3") > 0) + assertEquals(0, compareVGrades("V5", "V5")) + + assertTrue(compareVGrades("VB", "V1") < 0) + assertTrue(compareVGrades("V1", "VB") > 0) + } + + @Test + fun testClimbTypeValidation() { + val validTypes = listOf("BOULDER", "ROPE") + val invalidTypes = listOf("INVALID", "", "sport", "trad") + + validTypes.forEach { type -> assertTrue("$type should be valid", isValidClimbType(type)) } + + invalidTypes.forEach { type -> + assertFalse("$type should be invalid", isValidClimbType(type)) + } + } + + @Test + fun testImagePathValidation() { + val validPaths = listOf("image.jpg", "photo.jpeg", "picture.png", "diagram.webp") + + val invalidPaths = listOf("", "file.txt", "document.pdf", "video.mp4") + + validPaths.forEach { path -> + assertTrue("$path should be valid image", isValidImagePath(path)) + } + + invalidPaths.forEach { path -> + assertFalse("$path should be invalid image", isValidImagePath(path)) + } + } + + @Test + fun testLocationValidation() { + assertTrue(isValidLocation("Wall A")) + assertTrue(isValidLocation("Area 51")) + assertTrue(isValidLocation("Overhang Section")) + + assertFalse(isValidLocation("")) + assertFalse(isValidLocation(" ")) + assertFalse(isValidLocation(null)) + } + + @Test + fun testTagProcessing() { + val rawTags = "overhang, crimpy, technical,DYNAMIC " + val processedTags = processTags(rawTags) + + assertEquals(4, processedTags.size) + assertTrue(processedTags.contains("overhang")) + assertTrue(processedTags.contains("crimpy")) + assertTrue(processedTags.contains("technical")) + assertTrue(processedTags.contains("dynamic")) + } + + @Test + fun testSearchFiltering() { + val problems = + listOf( + ProblemData( + "id1", + "Crimpy Problem", + "BOULDER", + "V5", + listOf("crimpy", "overhang") + ), + ProblemData("id2", "Easy Route", "ROPE", "5.6", listOf("beginner", "slab")), + ProblemData( + "id3", + "Hard Boulder", + "BOULDER", + "V10", + listOf("powerful", "roof") + ) + ) + + val boulderProblems = filterByClimbType(problems, "BOULDER") + assertEquals(2, boulderProblems.size) + + val crimpyProblems = filterByTag(problems, "crimpy") + assertEquals(1, crimpyProblems.size) + + val easyProblems = filterByDifficultyRange(problems, "VB", "V6") + assertEquals(2, easyProblems.size) + } + + @Test + fun testDataSynchronization() { + val localData = mapOf("key1" to "local_value", "key2" to "shared_value") + val serverData = mapOf("key2" to "server_value", "key3" to "new_value") + + val merged = mergeData(localData, serverData) + + assertEquals(3, merged.size) + assertEquals("local_value", merged["key1"]) + assertEquals("server_value", merged["key2"]) // Server wins + assertEquals("new_value", merged["key3"]) + } + + @Test + fun testBackupValidation() { + val validBackup = + BackupData( + version = "2.0", + formatVersion = "2.0", + exportedAt = "2024-01-01T10:00:00Z", + dataCount = 5 + ) + + val invalidBackup = + BackupData( + version = "1.0", + formatVersion = "2.0", + exportedAt = "invalid-date", + dataCount = -1 + ) + + assertTrue(isValidBackup(validBackup)) + assertFalse(isValidBackup(invalidBackup)) + } + + // Helper functions and data classes + + private fun isValidString(str: String?): Boolean { + return str != null && str.trim().isNotEmpty() + } + + private fun isValidVGrade(grade: String): Boolean { + return grade.matches(Regex("^V(B|[0-9]|1[0-7])$")) + } + + private fun isValidYDSGrade(grade: String): Boolean { + return grade.matches(Regex("^5\\.[0-9]+([abcd])?$")) + } + + private fun isValidFontGrade(grade: String): Boolean { + return grade.matches(Regex("^[3-8][ABC]?\\+?$")) + } + + private fun extractVGradeNumber(grade: String): Int { + return when { + grade == "VB" -> 0 + grade.startsWith("V") -> grade.substring(1).toIntOrNull() ?: -1 + else -> -1 + } + } + + private fun calculateAttemptStatistics(attempts: List): AttemptStatistics { + val successful = attempts.count { it.result == "SUCCESS" || it.result == "FLASH" } + val avgDuration = attempts.map { it.duration }.average() + val successRate = (successful.toDouble() / attempts.size) * 100 + + return AttemptStatistics( + totalAttempts = attempts.size, + successfulAttempts = successful, + successRate = successRate, + averageDuration = avgDuration + ) + } + + private fun formatDuration(seconds: Long): String { + val hours = seconds / 3600 + val minutes = (seconds % 3600) / 60 + + return when { + hours > 0 && minutes > 0 -> "${hours}h ${minutes}m" + hours > 0 -> "${hours}h" + minutes > 0 -> "${minutes}m" + else -> "0m" + } + } + + private fun compareVGrades(grade1: String, grade2: String): Int { + val num1 = extractVGradeNumber(grade1) + val num2 = extractVGradeNumber(grade2) + return num1.compareTo(num2) + } + + private fun isValidClimbType(type: String): Boolean { + return type in listOf("BOULDER", "ROPE") + } + + private fun isValidImagePath(path: String): Boolean { + val validExtensions = listOf(".jpg", ".jpeg", ".png", ".webp") + return path.isNotEmpty() && validExtensions.any { path.endsWith(it, ignoreCase = true) } + } + + private fun isValidLocation(location: String?): Boolean { + return isValidString(location) + } + + private fun processTags(rawTags: String): List { + return rawTags.split(",").map { it.trim().lowercase() }.filter { it.isNotEmpty() } + } + + private fun filterByClimbType( + problems: List, + climbType: String + ): List { + return problems.filter { it.climbType == climbType } + } + + private fun filterByTag(problems: List, tag: String): List { + return problems.filter { it.tags.contains(tag) } + } + + private fun filterByDifficultyRange( + problems: List, + minGrade: String, + maxGrade: String + ): List { + return problems.filter { problem -> + if (problem.climbType == "BOULDER" && problem.difficulty.startsWith("V")) { + val gradeNum = extractVGradeNumber(problem.difficulty) + val minNum = extractVGradeNumber(minGrade) + val maxNum = extractVGradeNumber(maxGrade) + gradeNum in minNum..maxNum + } else { + true // Simplified for other grade systems + } + } + } + + private fun mergeData( + local: Map, + server: Map + ): Map { + return (local.keys + server.keys).associateWith { key -> server[key] ?: local[key]!! } + } + + private fun isValidBackup(backup: BackupData): Boolean { + return backup.version == "2.0" && + backup.formatVersion == "2.0" && + backup.exportedAt.matches(Regex("\\d{4}-\\d{2}-\\d{2}T\\d{2}:\\d{2}:\\d{2}Z")) && + backup.dataCount >= 0 + } + + // Data classes for testing + + data class AttemptData(val result: String, val duration: Int) + + data class AttemptStatistics( + val totalAttempts: Int, + val successfulAttempts: Int, + val successRate: Double, + val averageDuration: Double + ) + + data class ProblemData( + val id: String, + val name: String, + val climbType: String, + val difficulty: String, + val tags: List + ) + + data class BackupData( + val version: String, + val formatVersion: String, + val exportedAt: String, + val dataCount: Int + ) +} diff --git a/android/gradle/libs.versions.toml b/android/gradle/libs.versions.toml index 8b50bce..b4f55cf 100644 --- a/android/gradle/libs.versions.toml +++ b/android/gradle/libs.versions.toml @@ -19,7 +19,7 @@ kotlinxSerialization = "1.9.0" kotlinxCoroutines = "1.10.2" coil = "2.7.0" ksp = "2.2.10-2.0.2" -okhttp = "4.12.0" +okhttp = "5.1.0" [libraries] androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" } @@ -61,16 +61,11 @@ kotlinx-coroutines-android = { group = "org.jetbrains.kotlinx", name = "kotlinx- kotlinx-coroutines-test = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-test", version.ref = "kotlinxCoroutines" } # Testing -mockk = { group = "io.mockk", name = "mockk", version = "1.14.5" } +mockk = { group = "io.mockk", name = "mockk", version = "1.14.6" } # Image Loading coil-compose = { group = "io.coil-kt", name = "coil-compose", version.ref = "coil" } -# HTTP Client -okhttp = { group = "com.squareup.okhttp3", name = "okhttp", version.ref = "okhttp" } - - - [plugins] android-application = { id = "com.android.application", version.ref = "agp" } kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" } diff --git a/android/gradle/wrapper/gradle-wrapper.jar b/android/gradle/wrapper/gradle-wrapper.jar index 178a980..8bdaf60 100644 Binary files a/android/gradle/wrapper/gradle-wrapper.jar and b/android/gradle/wrapper/gradle-wrapper.jar differ diff --git a/android/gradle/wrapper/gradle-wrapper.properties b/android/gradle/wrapper/gradle-wrapper.properties index cc55842..37f853b 100644 --- a/android/gradle/wrapper/gradle-wrapper.properties +++ b/android/gradle/wrapper/gradle-wrapper.properties @@ -1,6 +1,7 @@ -#Fri Aug 15 11:23:25 MDT 2025 distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists distributionUrl=https\://services.gradle.org/distributions/gradle-8.13-bin.zip +networkTimeout=10000 +validateDistributionUrl=true zipStoreBase=GRADLE_USER_HOME zipStorePath=wrapper/dists diff --git a/android/gradlew b/android/gradlew index 4f906e0..adff685 100755 --- a/android/gradlew +++ b/android/gradlew @@ -1,7 +1,7 @@ -#!/usr/bin/env sh +#!/bin/sh # -# Copyright 2015 the original author or authors. +# Copyright ยฉ 2015 the original authors. # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. @@ -15,81 +15,114 @@ # See the License for the specific language governing permissions and # limitations under the License. # +# SPDX-License-Identifier: Apache-2.0 +# ############################################################################## -## -## Gradle start up script for UN*X -## +# +# Gradle start up script for POSIX generated by Gradle. +# +# Important for running: +# +# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is +# noncompliant, but you have some other compliant shell such as ksh or +# bash, then to run this script, type that shell name before the whole +# command line, like: +# +# ksh Gradle +# +# Busybox and similar reduced shells will NOT work, because this script +# requires all of these POSIX shell features: +# * functions; +# * expansions ยซ$varยป, ยซ${var}ยป, ยซ${var:-default}ยป, ยซ${var+SET}ยป, +# ยซ${var#prefix}ยป, ยซ${var%suffix}ยป, and ยซ$( cmd )ยป; +# * compound commands having a testable exit status, especially ยซcaseยป; +# * various built-in commands including ยซcommandยป, ยซsetยป, and ยซulimitยป. +# +# Important for patching: +# +# (2) This script targets any POSIX shell, so it avoids extensions provided +# by Bash, Ksh, etc; in particular arrays are avoided. +# +# The "traditional" practice of packing multiple parameters into a +# space-separated string is a well documented source of bugs and security +# problems, so this is (mostly) avoided, by progressively accumulating +# options in "$@", and eventually passing that to Java. +# +# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS, +# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly; +# see the in-line comments for details. +# +# There are tweaks for specific operating systems such as AIX, CygWin, +# Darwin, MinGW, and NonStop. +# +# (3) This script is generated from the Groovy template +# https://github.com/gradle/gradle/blob/HEAD/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt +# within the Gradle project. +# +# You can find Gradle at https://github.com/gradle/gradle/. +# ############################################################################## # Attempt to set APP_HOME + # Resolve links: $0 may be a link -PRG="$0" -# Need this for relative symlinks. -while [ -h "$PRG" ] ; do - ls=`ls -ld "$PRG"` - link=`expr "$ls" : '.*-> \(.*\)$'` - if expr "$link" : '/.*' > /dev/null; then - PRG="$link" - else - PRG=`dirname "$PRG"`"/$link" - fi +app_path=$0 + +# Need this for daisy-chained symlinks. +while + APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path + [ -h "$app_path" ] +do + ls=$( ls -ld "$app_path" ) + link=${ls#*' -> '} + case $link in #( + /*) app_path=$link ;; #( + *) app_path=$APP_HOME$link ;; + esac done -SAVED="`pwd`" -cd "`dirname \"$PRG\"`/" >/dev/null -APP_HOME="`pwd -P`" -cd "$SAVED" >/dev/null -APP_NAME="Gradle" -APP_BASE_NAME=`basename "$0"` - -# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. -DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' +# This is normally unused +# shellcheck disable=SC2034 +APP_BASE_NAME=${0##*/} +# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036) +APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || exit # Use the maximum available, or set MAX_FD != -1 to use that value. -MAX_FD="maximum" +MAX_FD=maximum warn () { echo "$*" -} +} >&2 die () { echo echo "$*" echo exit 1 -} +} >&2 # OS specific support (must be 'true' or 'false'). cygwin=false msys=false darwin=false nonstop=false -case "`uname`" in - CYGWIN* ) - cygwin=true - ;; - Darwin* ) - darwin=true - ;; - MINGW* ) - msys=true - ;; - NONSTOP* ) - nonstop=true - ;; +case "$( uname )" in #( + CYGWIN* ) cygwin=true ;; #( + Darwin* ) darwin=true ;; #( + MSYS* | MINGW* ) msys=true ;; #( + NONSTOP* ) nonstop=true ;; esac -CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar # Determine the Java command to use to start the JVM. if [ -n "$JAVA_HOME" ] ; then if [ -x "$JAVA_HOME/jre/sh/java" ] ; then # IBM's JDK on AIX uses strange locations for the executables - JAVACMD="$JAVA_HOME/jre/sh/java" + JAVACMD=$JAVA_HOME/jre/sh/java else - JAVACMD="$JAVA_HOME/bin/java" + JAVACMD=$JAVA_HOME/bin/java fi if [ ! -x "$JAVACMD" ] ; then die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME @@ -98,88 +131,118 @@ Please set the JAVA_HOME variable in your environment to match the location of your Java installation." fi else - JAVACMD="java" - which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. + JAVACMD=java + if ! command -v java >/dev/null 2>&1 + then + die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. Please set the JAVA_HOME variable in your environment to match the location of your Java installation." + fi fi # Increase the maximum file descriptors if we can. -if [ "$cygwin" = "false" -a "$darwin" = "false" -a "$nonstop" = "false" ] ; then - MAX_FD_LIMIT=`ulimit -H -n` - if [ $? -eq 0 ] ; then - if [ "$MAX_FD" = "maximum" -o "$MAX_FD" = "max" ] ; then - MAX_FD="$MAX_FD_LIMIT" - fi - ulimit -n $MAX_FD - if [ $? -ne 0 ] ; then - warn "Could not set maximum file descriptor limit: $MAX_FD" - fi - else - warn "Could not query maximum file descriptor limit: $MAX_FD_LIMIT" - fi -fi - -# For Darwin, add options to specify how the application appears in the dock -if $darwin; then - GRADLE_OPTS="$GRADLE_OPTS \"-Xdock:name=$APP_NAME\" \"-Xdock:icon=$APP_HOME/media/gradle.icns\"" -fi - -# For Cygwin or MSYS, switch paths to Windows format before running java -if [ "$cygwin" = "true" -o "$msys" = "true" ] ; then - APP_HOME=`cygpath --path --mixed "$APP_HOME"` - CLASSPATH=`cygpath --path --mixed "$CLASSPATH"` - - JAVACMD=`cygpath --unix "$JAVACMD"` - - # We build the pattern for arguments to be converted via cygpath - ROOTDIRSRAW=`find -L / -maxdepth 1 -mindepth 1 -type d 2>/dev/null` - SEP="" - for dir in $ROOTDIRSRAW ; do - ROOTDIRS="$ROOTDIRS$SEP$dir" - SEP="|" - done - OURCYGPATTERN="(^($ROOTDIRS))" - # Add a user-defined pattern to the cygpath arguments - if [ "$GRADLE_CYGPATTERN" != "" ] ; then - OURCYGPATTERN="$OURCYGPATTERN|($GRADLE_CYGPATTERN)" - fi - # Now convert the arguments - kludge to limit ourselves to /bin/sh - i=0 - for arg in "$@" ; do - CHECK=`echo "$arg"|egrep -c "$OURCYGPATTERN" -` - CHECK2=`echo "$arg"|egrep -c "^-"` ### Determine if an option - - if [ $CHECK -ne 0 ] && [ $CHECK2 -eq 0 ] ; then ### Added a condition - eval `echo args$i`=`cygpath --path --ignore --mixed "$arg"` - else - eval `echo args$i`="\"$arg\"" - fi - i=`expr $i + 1` - done - case $i in - 0) set -- ;; - 1) set -- "$args0" ;; - 2) set -- "$args0" "$args1" ;; - 3) set -- "$args0" "$args1" "$args2" ;; - 4) set -- "$args0" "$args1" "$args2" "$args3" ;; - 5) set -- "$args0" "$args1" "$args2" "$args3" "$args4" ;; - 6) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" ;; - 7) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" ;; - 8) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" ;; - 9) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" "$args8" ;; +if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then + case $MAX_FD in #( + max*) + # In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + MAX_FD=$( ulimit -H -n ) || + warn "Could not query maximum file descriptor limit" + esac + case $MAX_FD in #( + '' | soft) :;; #( + *) + # In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + ulimit -n "$MAX_FD" || + warn "Could not set maximum file descriptor limit to $MAX_FD" esac fi -# Escape application args -save () { - for i do printf %s\\n "$i" | sed "s/'/'\\\\''/g;1s/^/'/;\$s/\$/' \\\\/" ; done - echo " " -} -APP_ARGS=`save "$@"` +# Collect all arguments for the java command, stacking in reverse order: +# * args from the command line +# * the main class name +# * -classpath +# * -D...appname settings +# * --module-path (only if needed) +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables. -# Collect all arguments for the java command, following the shell quoting and substitution rules -eval set -- $DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS "\"-Dorg.gradle.appname=$APP_BASE_NAME\"" -classpath "\"$CLASSPATH\"" org.gradle.wrapper.GradleWrapperMain "$APP_ARGS" +# For Cygwin or MSYS, switch paths to Windows format before running java +if "$cygwin" || "$msys" ; then + APP_HOME=$( cygpath --path --mixed "$APP_HOME" ) + + JAVACMD=$( cygpath --unix "$JAVACMD" ) + + # Now convert the arguments - kludge to limit ourselves to /bin/sh + for arg do + if + case $arg in #( + -*) false ;; # don't mess with options #( + /?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath + [ -e "$t" ] ;; #( + *) false ;; + esac + then + arg=$( cygpath --path --ignore --mixed "$arg" ) + fi + # Roll the args list around exactly as many times as the number of + # args, so each arg winds up back in the position where it started, but + # possibly modified. + # + # NB: a `for` loop captures its iteration list before it begins, so + # changing the positional parameters here affects neither the number of + # iterations, nor the values presented in `arg`. + shift # remove old arg + set -- "$@" "$arg" # push replacement arg + done +fi + + +# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' + +# Collect all arguments for the java command: +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments, +# and any embedded shellness will be escaped. +# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be +# treated as '${Hostname}' itself on the command line. + +set -- \ + "-Dorg.gradle.appname=$APP_BASE_NAME" \ + -jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \ + "$@" + +# Stop when "xargs" is not available. +if ! command -v xargs >/dev/null 2>&1 +then + die "xargs is not available" +fi + +# Use "xargs" to parse quoted args. +# +# With -n1 it outputs one arg per line, with the quotes and backslashes removed. +# +# In Bash we could simply go: +# +# readarray ARGS < <( xargs -n1 <<<"$var" ) && +# set -- "${ARGS[@]}" "$@" +# +# but POSIX shell has neither arrays nor command substitution, so instead we +# post-process each arg (as a line of input to sed) to backslash-escape any +# character that might be a shell metacharacter, then use eval to reverse +# that process (while maintaining the separation between arguments), and wrap +# the whole thing up as a single "set" statement. +# +# This will of course break if any of these variables contains a newline or +# an unmatched quote. +# + +eval "set -- $( + printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" | + xargs -n1 | + sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' | + tr '\n' ' ' + )" '"$@"' exec "$JAVACMD" "$@" diff --git a/android/gradlew.bat b/android/gradlew.bat index ac1b06f..e509b2d 100644 --- a/android/gradlew.bat +++ b/android/gradlew.bat @@ -13,8 +13,10 @@ @rem See the License for the specific language governing permissions and @rem limitations under the License. @rem +@rem SPDX-License-Identifier: Apache-2.0 +@rem -@if "%DEBUG%" == "" @echo off +@if "%DEBUG%"=="" @echo off @rem ########################################################################## @rem @rem Gradle startup script for Windows @@ -25,7 +27,8 @@ if "%OS%"=="Windows_NT" setlocal set DIRNAME=%~dp0 -if "%DIRNAME%" == "" set DIRNAME=. +if "%DIRNAME%"=="" set DIRNAME=. +@rem This is normally unused set APP_BASE_NAME=%~n0 set APP_HOME=%DIRNAME% @@ -40,13 +43,13 @@ if defined JAVA_HOME goto findJavaFromJavaHome set JAVA_EXE=java.exe %JAVA_EXE% -version >NUL 2>&1 -if "%ERRORLEVEL%" == "0" goto execute +if %ERRORLEVEL% equ 0 goto execute -echo. -echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. -echo. -echo Please set the JAVA_HOME variable in your environment to match the -echo location of your Java installation. +echo. 1>&2 +echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 goto fail @@ -56,32 +59,33 @@ set JAVA_EXE=%JAVA_HOME%/bin/java.exe if exist "%JAVA_EXE%" goto execute -echo. -echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% -echo. -echo Please set the JAVA_HOME variable in your environment to match the -echo location of your Java installation. +echo. 1>&2 +echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 goto fail :execute @rem Setup the command line -set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar @rem Execute Gradle -"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %* +"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %* :end @rem End local scope for the variables with windows NT shell -if "%ERRORLEVEL%"=="0" goto mainEnd +if %ERRORLEVEL% equ 0 goto mainEnd :fail rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of rem the _cmd.exe /c_ return code! -if not "" == "%GRADLE_EXIT_CONSOLE%" exit 1 -exit /b 1 +set EXIT_CODE=%ERRORLEVEL% +if %EXIT_CODE% equ 0 set EXIT_CODE=1 +if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE% +exit /b %EXIT_CODE% :mainEnd if "%OS%"=="Windows_NT" endlocal diff --git a/android/test_backup/ClimbRepository.kt b/android/test_backup/ClimbRepository.kt deleted file mode 100644 index fe3483c..0000000 --- a/android/test_backup/ClimbRepository.kt +++ /dev/null @@ -1,383 +0,0 @@ -package com.atridad.openclimb.data.repository - -import android.content.Context -import com.atridad.openclimb.data.database.OpenClimbDatabase -import com.atridad.openclimb.data.format.ClimbDataBackup -import com.atridad.openclimb.data.model.* -import com.atridad.openclimb.utils.ZipExportImportUtils -import java.io.File -import java.time.LocalDateTime -import kotlinx.coroutines.flow.Flow -import kotlinx.coroutines.flow.first -import kotlinx.serialization.json.Json - -class ClimbRepository(database: OpenClimbDatabase, private val context: Context) { - private val gymDao = database.gymDao() - private val problemDao = database.problemDao() - private val sessionDao = database.climbSessionDao() - private val attemptDao = database.attemptDao() - - private val json = Json { - prettyPrint = true - ignoreUnknownKeys = true - } - - // Gym operations - fun getAllGyms(): Flow> = gymDao.getAllGyms() - suspend fun getGymById(id: String): Gym? = gymDao.getGymById(id) - suspend fun insertGym(gym: Gym) = gymDao.insertGym(gym) - suspend fun updateGym(gym: Gym) = gymDao.updateGym(gym) - suspend fun deleteGym(gym: Gym) = gymDao.deleteGym(gym) - fun searchGyms(query: String): Flow> = gymDao.searchGyms(query) - - // Problem operations - fun getAllProblems(): Flow> = problemDao.getAllProblems() - suspend fun getProblemById(id: String): Problem? = problemDao.getProblemById(id) - fun getProblemsByGym(gymId: String): Flow> = problemDao.getProblemsByGym(gymId) - suspend fun insertProblem(problem: Problem) = problemDao.insertProblem(problem) - suspend fun updateProblem(problem: Problem) = problemDao.updateProblem(problem) - suspend fun deleteProblem(problem: Problem) = problemDao.deleteProblem(problem) - fun searchProblems(query: String): Flow> = problemDao.searchProblems(query) - - // Session operations - fun getAllSessions(): Flow> = sessionDao.getAllSessions() - suspend fun getSessionById(id: String): ClimbSession? = sessionDao.getSessionById(id) - fun getSessionsByGym(gymId: String): Flow> = - sessionDao.getSessionsByGym(gymId) - suspend fun getActiveSession(): ClimbSession? = sessionDao.getActiveSession() - fun getActiveSessionFlow(): Flow = sessionDao.getActiveSessionFlow() - suspend fun insertSession(session: ClimbSession) = sessionDao.insertSession(session) - suspend fun updateSession(session: ClimbSession) = sessionDao.updateSession(session) - suspend fun deleteSession(session: ClimbSession) = sessionDao.deleteSession(session) - suspend fun getLastUsedGym(): Gym? { - val recentSessions = sessionDao.getRecentSessions(1).first() - return if (recentSessions.isNotEmpty()) { - getGymById(recentSessions.first().gymId) - } else { - null - } - } - - // Attempt operations - fun getAllAttempts(): Flow> = attemptDao.getAllAttempts() - fun getAttemptsBySession(sessionId: String): Flow> = - attemptDao.getAttemptsBySession(sessionId) - fun getAttemptsByProblem(problemId: String): Flow> = - attemptDao.getAttemptsByProblem(problemId) - suspend fun insertAttempt(attempt: Attempt) = attemptDao.insertAttempt(attempt) - suspend fun updateAttempt(attempt: Attempt) = attemptDao.updateAttempt(attempt) - suspend fun deleteAttempt(attempt: Attempt) = attemptDao.deleteAttempt(attempt) - - // ZIP Export with images - Single format for reliability - suspend fun exportAllDataToZip(directory: File? = null): File { - return try { - // Collect all data with proper error handling - val allGyms = gymDao.getAllGyms().first() - val allProblems = problemDao.getAllProblems().first() - val allSessions = sessionDao.getAllSessions().first() - val allAttempts = attemptDao.getAllAttempts().first() - - // Validate data integrity before export - validateDataIntegrity(allGyms, allProblems, allSessions, allAttempts) - - // Create backup data using platform-neutral format - val backupData = - ClimbDataBackup( - exportedAt = LocalDateTime.now().toString(), - version = "2.0", - formatVersion = "2.0", - gyms = - allGyms.map { - com.atridad.openclimb.data.format.BackupGym.fromGym(it) - }, - problems = - allProblems.map { - com.atridad.openclimb.data.format.BackupProblem.fromProblem( - it - ) - }, - sessions = - allSessions.map { - com.atridad.openclimb.data.format.BackupClimbSession - .fromClimbSession(it) - }, - attempts = - allAttempts.map { - com.atridad.openclimb.data.format.BackupAttempt.fromAttempt( - it - ) - } - ) - - // Collect all referenced image paths and validate they exist - val referencedImagePaths = allProblems.flatMap { it.imagePaths }.toSet() - val validImagePaths = - referencedImagePaths - .filter { imagePath -> - try { - val imageFile = - com.atridad.openclimb.utils.ImageUtils.getImageFile( - context, - imagePath - ) - imageFile.exists() && imageFile.length() > 0 - } catch (e: Exception) { - false - } - } - .toSet() - - // Log any missing images for debugging - val missingImages = referencedImagePaths - validImagePaths - if (missingImages.isNotEmpty()) { - android.util.Log.w( - "ClimbRepository", - "Some referenced images are missing: $missingImages" - ) - } - - ZipExportImportUtils.createExportZip( - context = context, - exportData = backupData, - referencedImagePaths = validImagePaths, - directory = directory - ) - } catch (e: Exception) { - throw Exception("Export failed: ${e.message}") - } - } - - suspend fun exportAllDataToZipUri(uri: android.net.Uri) { - try { - // Collect all data - val allGyms = gymDao.getAllGyms().first() - val allProblems = problemDao.getAllProblems().first() - val allSessions = sessionDao.getAllSessions().first() - val allAttempts = attemptDao.getAllAttempts().first() - - // Validate data integrity before export - validateDataIntegrity(allGyms, allProblems, allSessions, allAttempts) - - // Create backup data using platform-neutral format - val backupData = - ClimbDataBackup( - exportedAt = LocalDateTime.now().toString(), - version = "2.0", - formatVersion = "2.0", - gyms = - allGyms.map { - com.atridad.openclimb.data.format.BackupGym.fromGym(it) - }, - problems = - allProblems.map { - com.atridad.openclimb.data.format.BackupProblem.fromProblem( - it - ) - }, - sessions = - allSessions.map { - com.atridad.openclimb.data.format.BackupClimbSession - .fromClimbSession(it) - }, - attempts = - allAttempts.map { - com.atridad.openclimb.data.format.BackupAttempt.fromAttempt( - it - ) - } - ) - - // Collect all referenced image paths and validate they exist - val referencedImagePaths = allProblems.flatMap { it.imagePaths }.toSet() - val validImagePaths = - referencedImagePaths - .filter { imagePath -> - try { - val imageFile = - com.atridad.openclimb.utils.ImageUtils.getImageFile( - context, - imagePath - ) - imageFile.exists() && imageFile.length() > 0 - } catch (e: Exception) { - false - } - } - .toSet() - - ZipExportImportUtils.createExportZipToUri( - context = context, - uri = uri, - exportData = backupData, - referencedImagePaths = validImagePaths - ) - } catch (e: Exception) { - throw Exception("Export failed: ${e.message}") - } - } - - suspend fun importDataFromZip(file: File) { - try { - // Validate the ZIP file - if (!file.exists() || file.length() == 0L) { - throw Exception("Invalid ZIP file: file is empty or doesn't exist") - } - - // Extract and validate the ZIP contents - val importResult = ZipExportImportUtils.extractImportZip(context, file) - - // Validate JSON content - if (importResult.jsonContent.isBlank()) { - throw Exception("Invalid ZIP file: no data.json found or empty content") - } - - // Parse and validate the data structure - val importData = - try { - json.decodeFromString(importResult.jsonContent) - } catch (e: Exception) { - throw Exception("Invalid data format: ${e.message}") - } - - // Validate data integrity - validateImportData(importData) - - // Clear existing data to avoid conflicts - attemptDao.deleteAllAttempts() - sessionDao.deleteAllSessions() - problemDao.deleteAllProblems() - gymDao.deleteAllGyms() - - // Import gyms first (problems depend on gyms) - importData.gyms.forEach { backupGym -> - try { - gymDao.insertGym(backupGym.toGym()) - } catch (e: Exception) { - throw Exception("Failed to import gym '${backupGym.name}': ${e.message}") - } - } - - // Import problems with updated image paths - val updatedBackupProblems = - ZipExportImportUtils.updateProblemImagePaths( - importData.problems, - importResult.importedImagePaths - ) - - // Import problems (depends on gyms) - updatedBackupProblems.forEach { backupProblem -> - try { - problemDao.insertProblem(backupProblem.toProblem()) - } catch (e: Exception) { - throw Exception( - "Failed to import problem '${backupProblem.name}': ${e.message}" - ) - } - } - - // Import sessions - importData.sessions.forEach { backupSession -> - try { - sessionDao.insertSession(backupSession.toClimbSession()) - } catch (e: Exception) { - throw Exception("Failed to import session '${backupSession.id}': ${e.message}") - } - } - - // Import attempts last (depends on problems and sessions) - importData.attempts.forEach { backupAttempt -> - try { - attemptDao.insertAttempt(backupAttempt.toAttempt()) - } catch (e: Exception) { - throw Exception("Failed to import attempt '${backupAttempt.id}': ${e.message}") - } - } - } catch (e: Exception) { - throw Exception("Import failed: ${e.message}") - } - } - - private fun validateDataIntegrity( - gyms: List, - problems: List, - sessions: List, - attempts: List - ) { - // Validate that all problems reference valid gyms - val gymIds = gyms.map { it.id }.toSet() - val invalidProblems = problems.filter { it.gymId !in gymIds } - if (invalidProblems.isNotEmpty()) { - throw Exception( - "Data integrity error: ${invalidProblems.size} problems reference non-existent gyms" - ) - } - - // Validate that all sessions reference valid gyms - val invalidSessions = sessions.filter { it.gymId !in gymIds } - if (invalidSessions.isNotEmpty()) { - throw Exception( - "Data integrity error: ${invalidSessions.size} sessions reference non-existent gyms" - ) - } - - // Validate that all attempts reference valid problems and sessions - val problemIds = problems.map { it.id }.toSet() - val sessionIds = sessions.map { it.id }.toSet() - - val invalidAttempts = - attempts.filter { it.problemId !in problemIds || it.sessionId !in sessionIds } - if (invalidAttempts.isNotEmpty()) { - throw Exception( - "Data integrity error: ${invalidAttempts.size} attempts reference non-existent problems or sessions" - ) - } - } - - private fun validateImportData(importData: ClimbDataBackup) { - if (importData.gyms.isEmpty()) { - throw Exception("Import data is invalid: no gyms found") - } - - if (importData.version.isBlank()) { - throw Exception("Import data is invalid: no version information") - } - - // Check for reasonable data sizes to prevent malicious imports - if (importData.gyms.size > 1000 || - importData.problems.size > 10000 || - importData.sessions.size > 10000 || - importData.attempts.size > 100000 - ) { - throw Exception("Import data is too large: possible corruption or malicious file") - } - } - - suspend fun resetAllData() { - try { - // Clear all data from database - attemptDao.deleteAllAttempts() - sessionDao.deleteAllSessions() - problemDao.deleteAllProblems() - gymDao.deleteAllGyms() - - // Clear all images from storage - clearAllImages() - } catch (e: Exception) { - throw Exception("Reset failed: ${e.message}") - } - } - - private fun clearAllImages() { - try { - // Get the images directory - val imagesDir = File(context.filesDir, "images") - if (imagesDir.exists() && imagesDir.isDirectory) { - val deletedCount = imagesDir.listFiles()?.size ?: 0 - imagesDir.deleteRecursively() - android.util.Log.i("ClimbRepository", "Cleared $deletedCount image files") - } - } catch (e: Exception) { - android.util.Log.w("ClimbRepository", "Failed to clear some images: ${e.message}") - } - } -} diff --git a/ios/ClimbingActivityWidget/ClimbingActivityWidget.swift b/ios/ClimbingActivityWidget/ClimbingActivityWidget.swift index 32ccc37..fde3230 100644 --- a/ios/ClimbingActivityWidget/ClimbingActivityWidget.swift +++ b/ios/ClimbingActivityWidget/ClimbingActivityWidget.swift @@ -20,7 +20,7 @@ struct ClimbingActivityWidget: Widget { DynamicIsland { // Expanded UI goes here DynamicIslandExpandedRegion(.leading) { - Text("๐Ÿง—โ€โ™‚๏ธ") + Text("CLIMB") .font(.title2) } DynamicIslandExpandedRegion(.trailing) { @@ -39,12 +39,12 @@ struct ClimbingActivityWidget: Widget { .font(.caption) } } compactLeading: { - Text("๐Ÿง—โ€โ™‚๏ธ") + Text("CLIMB") } compactTrailing: { Text("\(context.state.totalAttempts)") .monospacedDigit() } minimal: { - Text("๐Ÿง—โ€โ™‚๏ธ") + Text("CLIMB") } } } @@ -56,7 +56,7 @@ struct LiveActivityView: View { var body: some View { VStack(alignment: .leading, spacing: 8) { HStack { - Text("๐Ÿง—โ€โ™‚๏ธ \(context.attributes.gymName)") + Text("CLIMBING: \(context.attributes.gymName)") .font(.headline) .lineLimit(1) Spacer() diff --git a/ios/OpenClimb.xcodeproj/project.pbxproj b/ios/OpenClimb.xcodeproj/project.pbxproj index 4698134..1deee67 100644 --- a/ios/OpenClimb.xcodeproj/project.pbxproj +++ b/ios/OpenClimb.xcodeproj/project.pbxproj @@ -15,6 +15,13 @@ /* End PBXBuildFile section */ /* Begin PBXContainerItemProxy section */ + D2F32FB12E90B26500B1BC56 /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = D24C19602E75002A0045894C /* Project object */; + proxyType = 1; + remoteGlobalIDString = D24C19672E75002A0045894C; + remoteInfo = OpenClimb; + }; D2FE949E2E78FEE1008CDB25 /* PBXContainerItemProxy */ = { isa = PBXContainerItemProxy; containerPortal = D24C19602E75002A0045894C /* Project object */; @@ -41,6 +48,7 @@ /* Begin PBXFileReference section */ D24C19682E75002A0045894C /* OpenClimb.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = OpenClimb.app; sourceTree = BUILT_PRODUCTS_DIR; }; D268B79E2E83894A003AA641 /* SessionStatusLiveExtension.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = SessionStatusLiveExtension.entitlements; sourceTree = ""; }; + D2F32FAD2E90B26500B1BC56 /* OpenClimbTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = OpenClimbTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; D2FE94802E78E958008CDB25 /* ActivityKit.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = ActivityKit.framework; path = System/Library/Frameworks/ActivityKit.framework; sourceTree = SDKROOT; }; D2FE948B2E78FEE0008CDB25 /* SessionStatusLiveExtension.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = SessionStatusLiveExtension.appex; sourceTree = BUILT_PRODUCTS_DIR; }; D2FE948C2E78FEE0008CDB25 /* WidgetKit.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = WidgetKit.framework; path = System/Library/Frameworks/WidgetKit.framework; sourceTree = SDKROOT; }; @@ -73,6 +81,11 @@ path = OpenClimb; sourceTree = ""; }; + D2F32FAE2E90B26500B1BC56 /* OpenClimbTests */ = { + isa = PBXFileSystemSynchronizedRootGroup; + path = OpenClimbTests; + sourceTree = ""; + }; D2FE94902E78FEE0008CDB25 /* SessionStatusLive */ = { isa = PBXFileSystemSynchronizedRootGroup; exceptions = ( @@ -92,6 +105,13 @@ ); runOnlyForDeploymentPostprocessing = 0; }; + D2F32FAA2E90B26500B1BC56 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; D2FE94882E78FEE0008CDB25 /* Frameworks */ = { isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; @@ -111,6 +131,7 @@ D268B79E2E83894A003AA641 /* SessionStatusLiveExtension.entitlements */, D24C196A2E75002A0045894C /* OpenClimb */, D2FE94902E78FEE0008CDB25 /* SessionStatusLive */, + D2F32FAE2E90B26500B1BC56 /* OpenClimbTests */, D2FE947F2E78E958008CDB25 /* Frameworks */, D24C19692E75002A0045894C /* Products */, ); @@ -121,6 +142,7 @@ children = ( D24C19682E75002A0045894C /* OpenClimb.app */, D2FE948B2E78FEE0008CDB25 /* SessionStatusLiveExtension.appex */, + D2F32FAD2E90B26500B1BC56 /* OpenClimbTests.xctest */, ); name = Products; sourceTree = ""; @@ -162,6 +184,29 @@ productReference = D24C19682E75002A0045894C /* OpenClimb.app */; productType = "com.apple.product-type.application"; }; + D2F32FAC2E90B26500B1BC56 /* OpenClimbTests */ = { + isa = PBXNativeTarget; + buildConfigurationList = D2F32FB52E90B26500B1BC56 /* Build configuration list for PBXNativeTarget "OpenClimbTests" */; + buildPhases = ( + D2F32FA92E90B26500B1BC56 /* Sources */, + D2F32FAA2E90B26500B1BC56 /* Frameworks */, + D2F32FAB2E90B26500B1BC56 /* Resources */, + ); + buildRules = ( + ); + dependencies = ( + D2F32FB22E90B26500B1BC56 /* PBXTargetDependency */, + ); + fileSystemSynchronizedGroups = ( + D2F32FAE2E90B26500B1BC56 /* OpenClimbTests */, + ); + name = OpenClimbTests; + packageProductDependencies = ( + ); + productName = OpenClimbTests; + productReference = D2F32FAD2E90B26500B1BC56 /* OpenClimbTests.xctest */; + productType = "com.apple.product-type.bundle.unit-test"; + }; D2FE948A2E78FEE0008CDB25 /* SessionStatusLiveExtension */ = { isa = PBXNativeTarget; buildConfigurationList = D2FE94A12E78FEE1008CDB25 /* Build configuration list for PBXNativeTarget "SessionStatusLiveExtension" */; @@ -197,6 +242,10 @@ D24C19672E75002A0045894C = { CreatedOnToolsVersion = 26.0; }; + D2F32FAC2E90B26500B1BC56 = { + CreatedOnToolsVersion = 26.0.1; + TestTargetID = D24C19672E75002A0045894C; + }; D2FE948A2E78FEE0008CDB25 = { CreatedOnToolsVersion = 26.0; }; @@ -218,6 +267,7 @@ targets = ( D24C19672E75002A0045894C /* OpenClimb */, D2FE948A2E78FEE0008CDB25 /* SessionStatusLiveExtension */, + D2F32FAC2E90B26500B1BC56 /* OpenClimbTests */, ); }; /* End PBXProject section */ @@ -230,6 +280,13 @@ ); runOnlyForDeploymentPostprocessing = 0; }; + D2F32FAB2E90B26500B1BC56 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; D2FE94892E78FEE0008CDB25 /* Resources */ = { isa = PBXResourcesBuildPhase; buildActionMask = 2147483647; @@ -247,6 +304,13 @@ ); runOnlyForDeploymentPostprocessing = 0; }; + D2F32FA92E90B26500B1BC56 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; D2FE94872E78FEE0008CDB25 /* Sources */ = { isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; @@ -257,6 +321,11 @@ /* End PBXSourcesBuildPhase section */ /* Begin PBXTargetDependency section */ + D2F32FB22E90B26500B1BC56 /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = D24C19672E75002A0045894C /* OpenClimb */; + targetProxy = D2F32FB12E90B26500B1BC56 /* PBXContainerItemProxy */; + }; D2FE949F2E78FEE1008CDB25 /* PBXTargetDependency */ = { isa = PBXTargetDependency; target = D2FE948A2E78FEE0008CDB25 /* SessionStatusLiveExtension */; @@ -396,7 +465,7 @@ CODE_SIGN_ENTITLEMENTS = OpenClimb/OpenClimb.entitlements; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 12; + CURRENT_PROJECT_VERSION = 13; DEVELOPMENT_TEAM = 4BC9Y2LL4B; ENABLE_PREVIEWS = YES; GENERATE_INFOPLIST_FILE = YES; @@ -416,7 +485,7 @@ "$(inherited)", "@executable_path/Frameworks", ); - MARKETING_VERSION = 1.2.1; + MARKETING_VERSION = 1.2.2; PRODUCT_BUNDLE_IDENTIFIER = com.atridad.OpenClimb; PRODUCT_NAME = "$(TARGET_NAME)"; PROVISIONING_PROFILE_SPECIFIER = ""; @@ -439,7 +508,7 @@ CODE_SIGN_ENTITLEMENTS = OpenClimb/OpenClimb.entitlements; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 12; + CURRENT_PROJECT_VERSION = 13; DEVELOPMENT_TEAM = 4BC9Y2LL4B; ENABLE_PREVIEWS = YES; GENERATE_INFOPLIST_FILE = YES; @@ -459,7 +528,7 @@ "$(inherited)", "@executable_path/Frameworks", ); - MARKETING_VERSION = 1.2.1; + MARKETING_VERSION = 1.2.2; PRODUCT_BUNDLE_IDENTIFIER = com.atridad.OpenClimb; PRODUCT_NAME = "$(TARGET_NAME)"; PROVISIONING_PROFILE_SPECIFIER = ""; @@ -474,6 +543,48 @@ }; name = Release; }; + D2F32FB32E90B26500B1BC56 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + BUNDLE_LOADER = "$(TEST_HOST)"; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + DEVELOPMENT_TEAM = 4BC9Y2LL4B; + GENERATE_INFOPLIST_FILE = YES; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = com.atri.dad.OpenClimb.Watch.OpenClimbTests; + PRODUCT_NAME = "$(TARGET_NAME)"; + STRING_CATALOG_GENERATE_SYMBOLS = NO; + SWIFT_APPROACHABLE_CONCURRENCY = YES; + SWIFT_EMIT_LOC_STRINGS = NO; + SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/OpenClimb.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/OpenClimb"; + }; + name = Debug; + }; + D2F32FB42E90B26500B1BC56 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + BUNDLE_LOADER = "$(TEST_HOST)"; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + DEVELOPMENT_TEAM = 4BC9Y2LL4B; + GENERATE_INFOPLIST_FILE = YES; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = com.atri.dad.OpenClimb.Watch.OpenClimbTests; + PRODUCT_NAME = "$(TARGET_NAME)"; + STRING_CATALOG_GENERATE_SYMBOLS = NO; + SWIFT_APPROACHABLE_CONCURRENCY = YES; + SWIFT_EMIT_LOC_STRINGS = NO; + SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/OpenClimb.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/OpenClimb"; + }; + name = Release; + }; D2FE94A22E78FEE1008CDB25 /* Debug */ = { isa = XCBuildConfiguration; buildSettings = { @@ -481,7 +592,7 @@ ASSETCATALOG_COMPILER_WIDGET_BACKGROUND_COLOR_NAME = WidgetBackground; CODE_SIGN_ENTITLEMENTS = SessionStatusLiveExtension.entitlements; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 12; + CURRENT_PROJECT_VERSION = 13; DEVELOPMENT_TEAM = 4BC9Y2LL4B; GENERATE_INFOPLIST_FILE = YES; INFOPLIST_FILE = SessionStatusLive/Info.plist; @@ -492,7 +603,7 @@ "@executable_path/Frameworks", "@executable_path/../../Frameworks", ); - MARKETING_VERSION = 1.2.1; + MARKETING_VERSION = 1.2.2; PRODUCT_BUNDLE_IDENTIFIER = com.atridad.OpenClimb.SessionStatusLive; PRODUCT_NAME = "$(TARGET_NAME)"; SKIP_INSTALL = YES; @@ -511,7 +622,7 @@ ASSETCATALOG_COMPILER_WIDGET_BACKGROUND_COLOR_NAME = WidgetBackground; CODE_SIGN_ENTITLEMENTS = SessionStatusLiveExtension.entitlements; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 12; + CURRENT_PROJECT_VERSION = 13; DEVELOPMENT_TEAM = 4BC9Y2LL4B; GENERATE_INFOPLIST_FILE = YES; INFOPLIST_FILE = SessionStatusLive/Info.plist; @@ -522,7 +633,7 @@ "@executable_path/Frameworks", "@executable_path/../../Frameworks", ); - MARKETING_VERSION = 1.2.1; + MARKETING_VERSION = 1.2.2; PRODUCT_BUNDLE_IDENTIFIER = com.atridad.OpenClimb.SessionStatusLive; PRODUCT_NAME = "$(TARGET_NAME)"; SKIP_INSTALL = YES; @@ -555,6 +666,15 @@ defaultConfigurationIsVisible = 0; defaultConfigurationName = Release; }; + D2F32FB52E90B26500B1BC56 /* Build configuration list for PBXNativeTarget "OpenClimbTests" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + D2F32FB32E90B26500B1BC56 /* Debug */, + D2F32FB42E90B26500B1BC56 /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; D2FE94A12E78FEE1008CDB25 /* Build configuration list for PBXNativeTarget "SessionStatusLiveExtension" */ = { isa = XCConfigurationList; buildConfigurations = ( 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 328c1d7..f619bc0 100644 Binary files a/ios/OpenClimb.xcodeproj/project.xcworkspace/xcuserdata/atridad.xcuserdatad/UserInterfaceState.xcuserstate and b/ios/OpenClimb.xcodeproj/project.xcworkspace/xcuserdata/atridad.xcuserdatad/UserInterfaceState.xcuserstate differ diff --git a/ios/OpenClimb.xcodeproj/xcshareddata/xcschemes/OpenClimb.xcscheme b/ios/OpenClimb.xcodeproj/xcshareddata/xcschemes/OpenClimb.xcscheme index 8f80ddb..e330960 100644 --- a/ios/OpenClimb.xcodeproj/xcshareddata/xcschemes/OpenClimb.xcscheme +++ b/ios/OpenClimb.xcodeproj/xcshareddata/xcschemes/OpenClimb.xcscheme @@ -28,6 +28,17 @@ selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB" shouldUseLaunchSchemeArgsEnv = "YES"> + + + + + + + + + + \(localTimestamp)") print(" Server exportedAt: '\(serverBackup.exportedAt)' -> \(serverTimestamp)") print( @@ -261,14 +261,14 @@ class SyncService: ObservableObject { if localTimestamp > serverTimestamp { // Local is newer - replace server with local data - print("๐Ÿ”„ iOS SYNC: Case 3a - Local data is newer, replacing server content") + print("iOS SYNC: Case 3a - Local data is newer, replacing server content") let currentBackup = createBackupFromDataManager(dataManager) _ = try await uploadData(currentBackup) try await syncImagesToServer(dataManager: dataManager) print("Server replaced with local data") } else if serverTimestamp > localTimestamp { // Server is newer - replace local with server data - print("๐Ÿ”„ iOS SYNC: Case 3b - Server data is newer, replacing local content") + print("iOS SYNC: Case 3b - Server data is newer, replacing local content") let imagePathMapping = try await syncImagesFromServer( backup: serverBackup, dataManager: dataManager) try importBackupToDataManager( @@ -277,7 +277,7 @@ class SyncService: ObservableObject { } else { // Timestamps are equal - no sync needed print( - "๐Ÿ”„ iOS SYNC: Case 3c - Data is in sync (timestamps equal), no action needed" + "iOS SYNC: Case 3c - Data is in sync (timestamps equal), no action needed" ) } } else { diff --git a/ios/OpenClimb/Utils/DataStateManager.swift b/ios/OpenClimb/Utils/DataStateManager.swift index d533284..e7ff5ea 100644 --- a/ios/OpenClimb/Utils/DataStateManager.swift +++ b/ios/OpenClimb/Utils/DataStateManager.swift @@ -3,8 +3,7 @@ import Foundation -/// Manages the overall data state timestamp for sync purposes. This tracks when any data in the -/// local database was last modified, independent of individual entity timestamps. +/// Manages the overall data state timestamp for sync purposes class DataStateManager { private let userDefaults = UserDefaults.standard @@ -14,7 +13,6 @@ class DataStateManager { static let initialized = "openclimb_data_state_initialized" } - /// Shared instance for app-wide use static let shared = DataStateManager() private init() { @@ -36,21 +34,21 @@ class DataStateManager { func updateDataState() { let now = ISO8601DateFormatter().string(from: Date()) userDefaults.set(now, forKey: Keys.lastModified) - print("๐Ÿ“ iOS Data state updated to: \(now)") + print("iOS Data state updated to: \(now)") } /// Gets the current data state timestamp. This represents when any data was last modified /// locally. func getLastModified() -> String { if let storedTimestamp = userDefaults.string(forKey: Keys.lastModified) { - print("๐Ÿ“… iOS DataStateManager returning stored timestamp: \(storedTimestamp)") + print("iOS DataStateManager returning stored timestamp: \(storedTimestamp)") return storedTimestamp } // If no timestamp is stored, return epoch time to indicate very old data // This ensures server data will be considered newer than uninitialized local data let epochTime = "1970-01-01T00:00:00.000Z" - print("โš ๏ธ No data state timestamp found - returning epoch time: \(epochTime)") + print("WARNING: No data state timestamp found - returning epoch time: \(epochTime)") return epochTime } diff --git a/ios/OpenClimb/Utils/IconTestView.swift b/ios/OpenClimb/Utils/IconTestView.swift index 0332043..cc682f3 100644 --- a/ios/OpenClimb/Utils/IconTestView.swift +++ b/ios/OpenClimb/Utils/IconTestView.swift @@ -1,4 +1,3 @@ - import Combine import SwiftUI @@ -11,7 +10,7 @@ import SwiftUI @State private var testResults: [String] = [] var body: some View { - NavigationView { + NavigationStack { List { StatusSection() @@ -263,10 +262,10 @@ import SwiftUI ForEach(testResults.indices, id: \.self) { index in HStack { Image( - systemName: testResults[index].contains("โœ…") + systemName: testResults[index].contains("PASS") ? "checkmark.circle.fill" : "exclamationmark.triangle.fill" ) - .foregroundColor(testResults[index].contains("โœ…") ? .green : .orange) + .foregroundColor(testResults[index].contains("PASS") ? .green : .orange) Text(testResults[index]) .font(.caption) @@ -286,24 +285,24 @@ import SwiftUI // Test 1: Check iOS version compatibility if iconHelper.supportsModernIconFeatures { - testResults.append("โœ… iOS 17+ features supported") + testResults.append("PASS: iOS 17+ features supported") } else { testResults.append( - "โš ๏ธ Running on iOS version that doesn't support modern icon features") + "WARNING: 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") + testResults.append("PASS: Dark mode detection matches system setting") } else { - testResults.append("โš ๏ธ Dark mode detection mismatch") + testResults.append("WARNING: Dark mode detection mismatch") } // Test 3: Check recommended variant let variant = iconHelper.getRecommendedIconVariant(for: colorScheme) - testResults.append("โœ… Recommended icon variant: \(variant.description)") + testResults.append("PASS: Recommended icon variant: \(variant.description)") // Test 4: Test asset availability validateAssetConfiguration() @@ -316,7 +315,7 @@ import SwiftUI iconHelper.updateDarkModeStatus(for: colorScheme) let variant = iconHelper.getRecommendedIconVariant(for: colorScheme) testResults.append( - "โœ… Icon appearance test completed - Current variant: \(variant.description)") + "PASS: Icon appearance test completed - Current variant: \(variant.description)") } private func validateAssetConfiguration() { @@ -327,20 +326,20 @@ import SwiftUI ] for asset in expectedAssets { - testResults.append("โœ… Asset '\(asset)' configuration found") + testResults.append("PASS: Asset '\(asset)' configuration found") } } private func checkBundleResources() { // Check bundle identifier let bundleId = Bundle.main.bundleIdentifier ?? "Unknown" - testResults.append("โœ… Bundle ID: \(bundleId)") + testResults.append("PASS: 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))") + testResults.append("PASS: App version: \(version) (\(build))") } } @@ -364,7 +363,7 @@ import SwiftUI @Environment(\.colorScheme) private var colorScheme var body: some View { - NavigationView { + NavigationStack { VStack(spacing: 30) { Text("Icon Appearance Comparison") .font(.title2) diff --git a/ios/OpenClimb/Utils/ImageManager.swift b/ios/OpenClimb/Utils/ImageManager.swift index a8d74db..77afcf9 100644 --- a/ios/OpenClimb/Utils/ImageManager.swift +++ b/ios/OpenClimb/Utils/ImageManager.swift @@ -23,7 +23,7 @@ class ImageManager { // Final integrity check if !validateStorageIntegrity() { - print("๐Ÿšจ CRITICAL: Storage integrity compromised - attempting emergency recovery") + print("CRITICAL: Storage integrity compromised - attempting emergency recovery") emergencyImageRestore() } @@ -69,9 +69,9 @@ class ImageManager { attributes: [ .protectionKey: FileProtectionType.completeUntilFirstUserAuthentication ]) - print("โœ… Created directory: \(directory.path)") + print("Created directory: \(directory.path)") } catch { - print("โŒ Failed to create directory \(directory.path): \(error)") + print("ERROR: Failed to create directory \(directory.path): \(error)") } } } @@ -88,9 +88,9 @@ class ImageManager { var backupURL = backupDirectory try imagesURL.setResourceValues(resourceValues) try backupURL.setResourceValues(resourceValues) - print("โœ… Excluded image directories from iCloud backup") + print("Excluded image directories from iCloud backup") } catch { - print("โš ๏ธ Failed to exclude from iCloud backup: \(error)") + print("WARNING: Failed to exclude from iCloud backup: \(error)") } } @@ -114,11 +114,11 @@ class ImageManager { } private func performRobustMigration() { - print("๐Ÿ”„ Starting robust image migration system...") + print("Starting robust image migration system...") // Check for interrupted migration if let incompleteState = loadMigrationState() { - print("๐Ÿ”ง Detected interrupted migration, resuming...") + print("Detected interrupted migration, resuming...") resumeMigration(from: incompleteState) } else { // Start fresh migration @@ -135,7 +135,7 @@ class ImageManager { private func startNewMigration() { // First check for images in previous Application Support directories if let previousAppSupportImages = findPreviousAppSupportImages() { - print("๐Ÿ“ Found images in previous Application Support directory") + print("Found images in previous Application Support directory") migratePreviousAppSupportImages(from: previousAppSupportImages) return } @@ -145,7 +145,7 @@ class ImageManager { let hasLegacyImportImages = fileManager.fileExists(atPath: legacyImportImagesDirectory.path) guard hasLegacyImages || hasLegacyImportImages else { - print("โœ… No legacy images to migrate") + print("No legacy images to migrate") return } @@ -160,7 +160,7 @@ class ImageManager { let legacyFiles = try fileManager.contentsOfDirectory( atPath: legacyImagesDirectory.path) allLegacyFiles.append(contentsOf: legacyFiles) - print("๐Ÿ“ฆ Found \(legacyFiles.count) images in OpenClimbImages") + print("Found \(legacyFiles.count) images in OpenClimbImages") } // Collect files from Documents/images directory @@ -168,10 +168,10 @@ class ImageManager { let importFiles = try fileManager.contentsOfDirectory( atPath: legacyImportImagesDirectory.path) allLegacyFiles.append(contentsOf: importFiles) - print("๐Ÿ“ฆ Found \(importFiles.count) images in Documents/images") + print("Found \(importFiles.count) images in Documents/images") } - print("๐Ÿ“ฆ Total legacy images to migrate: \(allLegacyFiles.count)") + print("Total legacy images to migrate: \(allLegacyFiles.count)") let initialState = MigrationState( version: MigrationState.currentVersion, @@ -186,24 +186,24 @@ class ImageManager { performMigrationWithCheckpoints(files: allLegacyFiles, currentState: initialState) } catch { - print("โŒ Failed to start migration: \(error)") + print("ERROR: Failed to start migration: \(error)") } } private func resumeMigration(from state: MigrationState) { - print("๐Ÿ”„ Resuming migration from checkpoint...") - print("๐Ÿ“Š Progress: \(state.completedFiles.count)/\(state.totalFiles)") + 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") + print("Resuming with \(remainingFiles.count) remaining files") performMigrationWithCheckpoints(files: remainingFiles, currentState: state) } catch { - print("โŒ Failed to resume migration: \(error)") + print("ERROR: Failed to resume migration: \(error)") // Fallback: start fresh removeMigrationState() startNewMigration() @@ -270,11 +270,11 @@ class ImageManager { completedFiles.append(fileName) migratedCount += 1 - print("โœ… Migrated: \(fileName) (\(migratedCount)/\(currentState.totalFiles))") + print("Migrated: \(fileName) (\(migratedCount)/\(currentState.totalFiles))") } catch { failedCount += 1 - print("โŒ Failed to migrate \(fileName): \(error)") + print("ERROR: Failed to migrate \(fileName): \(error)") } // Save checkpoint every 5 files or if interrupted @@ -288,7 +288,7 @@ class ImageManager { lastCheckpoint: Date() ) saveMigrationState(checkpointState) - print("๐Ÿ’พ Checkpoint saved: \(completedFiles.count)/\(currentState.totalFiles)") + print("Checkpoint saved: \(completedFiles.count)/\(currentState.totalFiles)") } } } @@ -304,7 +304,7 @@ class ImageManager { ) saveMigrationState(finalState) - print("๐Ÿ Migration complete: \(migratedCount) migrated, \(failedCount) failed") + print("Migration complete: \(migratedCount) migrated, \(failedCount) failed") // Clean up legacy directory if no failures if failedCount == 0 { @@ -313,7 +313,7 @@ class ImageManager { } private func verifyMigrationIntegrity() { - print("๐Ÿ” Verifying migration integrity...") + print("Verifying migration integrity...") var allLegacyFiles = Set() @@ -331,12 +331,12 @@ class ImageManager { allLegacyFiles.formUnion(importFiles) } } catch { - print("โŒ Failed to read legacy directories: \(error)") + print("ERROR: Failed to read legacy directories: \(error)") return } guard !allLegacyFiles.isEmpty else { - print("โœ… No legacy directories to verify against") + print("No legacy directories to verify against") return } @@ -347,10 +347,10 @@ class ImageManager { let missingFiles = allLegacyFiles.subtracting(migratedFiles) if missingFiles.isEmpty { - print("โœ… Migration integrity verified - all files present") + print("Migration integrity verified - all files present") cleanupLegacyDirectory() } else { - print("โš ๏ธ Missing \(missingFiles.count) files, re-triggering migration") + print("WARNING: Missing \(missingFiles.count) files, re-triggering migration") // Re-trigger migration for missing files performMigrationWithCheckpoints( files: Array(missingFiles), @@ -364,16 +364,16 @@ class ImageManager { )) } } catch { - print("โŒ Failed to verify migration integrity: \(error)") + print("ERROR: Failed to verify migration integrity: \(error)") } } private func cleanupLegacyDirectory() { do { try fileManager.removeItem(at: legacyImagesDirectory) - print("๐Ÿ—‘๏ธ Cleaned up legacy directory") + print("Cleaned up legacy directory") } catch { - print("โš ๏ธ Failed to clean up legacy directory: \(error)") + print("WARNING: Failed to clean up legacy directory: \(error)") } } @@ -395,14 +395,14 @@ class ImageManager { // Check if state is too old (more than 1 hour) if Date().timeIntervalSince(state.lastCheckpoint) > 3600 { - print("โš ๏ธ Migration state is stale, starting fresh") + print("WARNING: Migration state is stale, starting fresh") removeMigrationState() return nil } return state.isComplete ? nil : state } catch { - print("โŒ Failed to load migration state: \(error)") + print("ERROR: Failed to load migration state: \(error)") removeMigrationState() return nil } @@ -413,7 +413,7 @@ class ImageManager { let data = try JSONEncoder().encode(state) try data.write(to: migrationStateURL) } catch { - print("โŒ Failed to save migration state: \(error)") + print("ERROR: Failed to save migration state: \(error)") } } @@ -429,7 +429,7 @@ class ImageManager { private func cleanupMigrationState() { try? fileManager.removeItem(at: migrationStateURL) try? fileManager.removeItem(at: migrationLockURL) - print("๐Ÿงน Cleaned up migration state files") + print("Cleaned up migration state files") } func saveImageData(_ data: Data, withName name: String? = nil) -> String? { @@ -444,10 +444,10 @@ class ImageManager { // Create backup copy try data.write(to: backupPath) - print("โœ… Saved image with backup: \(fileName)") + print("Saved image with backup: \(fileName)") return fileName } catch { - print("โŒ Failed to save image \(fileName): \(error)") + print("ERROR: Failed to save image \(fileName): \(error)") return nil } } @@ -467,7 +467,7 @@ class ImageManager { if fileManager.fileExists(atPath: backupPath.path), let data = try? Data(contentsOf: backupPath) { - print("๐Ÿ“ฆ Restored image from backup: \(path)") + print("Restored image from backup: \(path)") // Restore to primary location try? data.write(to: URL(fileURLWithPath: primaryPath)) @@ -497,7 +497,7 @@ class ImageManager { do { try fileManager.removeItem(atPath: primaryPath) } catch { - print("โŒ Failed to delete primary image at \(primaryPath): \(error)") + print("ERROR: Failed to delete primary image at \(primaryPath): \(error)") success = false } } @@ -507,7 +507,7 @@ class ImageManager { do { try fileManager.removeItem(at: backupPath) } catch { - print("โŒ Failed to delete backup image at \(backupPath.path): \(error)") + print("ERROR: Failed to delete backup image at \(backupPath.path): \(error)") success = false } } @@ -544,7 +544,7 @@ class ImageManager { } func performMaintenance() { - print("๐Ÿ”ง Starting image maintenance...") + print("Starting image maintenance...") syncBackups() validateImageIntegrity() @@ -562,11 +562,11 @@ class ImageManager { let backupPath = backupDirectory.appendingPathComponent(fileName) try? fileManager.copyItem(at: primaryPath, to: backupPath) - print("๐Ÿ”„ Created missing backup for: \(fileName)") + print("Created missing backup for: \(fileName)") } } } catch { - print("โŒ Failed to sync backups: \(error)") + print("ERROR: Failed to sync backups: \(error)") } } @@ -585,15 +585,15 @@ class ImageManager { } } - print("โœ… Validated \(validFiles) of \(files.count) image files") + print("Validated \(validFiles) of \(files.count) image files") } catch { - print("โŒ Failed to validate images: \(error)") + print("ERROR: 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") + print("Cleanup would require coordination with data manager") } func getStorageInfo() -> (primaryCount: Int, backupCount: Int, totalSize: Int64) { @@ -623,7 +623,7 @@ class ImageManager { let previousDir = findPreviousAppSupportImages() print( """ - ๐Ÿ“ OpenClimb Image Storage: + OpenClimb Image Storage: - App Support: \(appSupportDirectory.path) - Images: \(imagesDirectory.path) (\(info.primaryCount) files) - Backups: \(backupDirectory.path) (\(info.backupCount) files) @@ -635,7 +635,7 @@ class ImageManager { } func forceRecoveryMigration() { - print("๐Ÿšจ FORCE RECOVERY: Starting manual migration recovery...") + print("FORCE RECOVERY: Starting manual migration recovery...") // Remove any stale state removeMigrationState() @@ -644,7 +644,7 @@ class ImageManager { // Force fresh migration startNewMigration() - print("๐Ÿšจ FORCE RECOVERY: Migration recovery completed") + print("FORCE RECOVERY: Migration recovery completed") } func saveImportedImage(_ imageData: Data, filename: String) throws -> String { @@ -657,12 +657,12 @@ class ImageManager { // Create backup try? imageData.write(to: backupPath) - print("๐Ÿ“ฅ Imported image: \(filename)") + print("Imported image: \(filename)") return filename } func emergencyImageRestore() { - print("๐Ÿ†˜ EMERGENCY: Attempting image restoration...") + print("EMERGENCY: Attempting image restoration...") // Try to restore from backup directory do { @@ -680,14 +680,14 @@ class ImageManager { } } - print("๐Ÿ†˜ EMERGENCY: Restored \(restoredCount) images from backup") + print("EMERGENCY: Restored \(restoredCount) images from backup") } catch { - print("๐Ÿ†˜ EMERGENCY: Failed to restore from backup: \(error)") + 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...") + print("EMERGENCY: Found previous Application Support images, migrating...") migratePreviousAppSupportImages(from: previousAppSupportImages) return } @@ -696,21 +696,21 @@ class ImageManager { if fileManager.fileExists(atPath: legacyImagesDirectory.path) || fileManager.fileExists(atPath: legacyImportImagesDirectory.path) { - print("๐Ÿ†˜ EMERGENCY: Attempting legacy migration as fallback...") + print("EMERGENCY: Attempting legacy migration as fallback...") forceRecoveryMigration() } } func debugSafeInitialization() -> Bool { - print("๐Ÿ› DEBUG SAFE: Performing debug-safe initialization check...") + print("DEBUG SAFE: Performing debug-safe initialization check...") // Check if we're in a debug environment #if DEBUG - print("๐Ÿ› DEBUG SAFE: Debug environment detected") + 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") + print("DEBUG SAFE: Found migration lock - likely debug interruption") // Give extra time for file system to stabilize Thread.sleep(forTimeInterval: 1.0) @@ -732,14 +732,14 @@ class ImageManager { ((try? fileManager.contentsOfDirectory(atPath: backupDirectory.path)) ?? []).count > 0 if primaryEmpty && backupHasFiles { - print("๐Ÿ› DEBUG SAFE: Primary empty but backup exists - restoring") + 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") + print("DEBUG SAFE: Primary empty but found previous Application Support images") migratePreviousAppSupportImages(from: previousAppSupportImages) return true } @@ -755,13 +755,15 @@ class ImageManager { // 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") + print( + "WARNING 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") + print("WARNING INTEGRITY: Primary storage empty but backups exist") return false } @@ -775,7 +777,7 @@ class ImageManager { for: .applicationSupportDirectory, in: .userDomainMask ).first else { - print("โŒ Could not access Application Support directory") + print("ERROR: Could not access Application Support directory") return nil } @@ -808,13 +810,13 @@ class ImageManager { } } } catch { - print("โŒ Error scanning for previous Application Support directories: \(error)") + print("ERROR: Error scanning for previous Application Support directories: \(error)") } return nil } private func migratePreviousAppSupportImages(from sourceDirectory: URL) { - print("๐Ÿ”„ Migrating images from previous Application Support directory") + print("Migrating images from previous Application Support directory") do { let imageFiles = try fileManager.contentsOfDirectory(atPath: sourceDirectory.path) @@ -837,17 +839,17 @@ class ImageManager { // Create backup try? fileManager.copyItem(at: sourcePath, to: backupPath) - print("โœ… Migrated: \(fileName)") + print("Migrated: \(fileName)") } catch { - print("โŒ Failed to migrate \(fileName): \(error)") + print("ERROR: Failed to migrate \(fileName): \(error)") } } } - print("โœ… Completed migration from previous Application Support directory") + print("Completed migration from previous Application Support directory") } catch { - print("โŒ Failed to migrate from previous Application Support: \(error)") + print("ERROR: Failed to migrate from previous Application Support: \(error)") } } } diff --git a/ios/OpenClimb/Utils/ImageNamingUtils.swift b/ios/OpenClimb/Utils/ImageNamingUtils.swift index 8aca2d1..b15eabb 100644 --- a/ios/OpenClimb/Utils/ImageNamingUtils.swift +++ b/ios/OpenClimb/Utils/ImageNamingUtils.swift @@ -4,54 +4,36 @@ import CryptoKit import Foundation -/// Utility for creating consistent image filenames across iOS and Android platforms. -/// Uses deterministic naming based on problem ID and timestamp to ensure sync compatibility. +/// Utility for creating consistent image filenames across platforms class ImageNamingUtils { private static let imageExtension = ".jpg" - private static let hashLength = 12 // First 12 chars of SHA-256 + private static let hashLength = 12 - /// Generates a deterministic filename for a problem image. - /// Format: "problem_{hash}_{index}.jpg" - /// - /// - Parameters: - /// - problemId: The ID of the problem this image belongs to - /// - timestamp: ISO8601 timestamp when the image was created - /// - imageIndex: The index of this image for the problem (0, 1, 2, etc.) - /// - Returns: A consistent filename that will be the same across platforms + /// Generates a deterministic filename for a problem image static func generateImageFilename(problemId: String, timestamp: String, imageIndex: Int) -> String { - // Create a deterministic hash from problemId + timestamp + index + let input = "\(problemId)_\(timestamp)_\(imageIndex)" let hash = createHash(from: input) return "problem_\(hash)_\(imageIndex)\(imageExtension)" } - /// Generates a deterministic filename for a problem image using current timestamp. - /// - /// - Parameters: - /// - problemId: The ID of the problem this image belongs to - /// - imageIndex: The index of this image for the problem (0, 1, 2, etc.) - /// - Returns: A consistent filename + /// Generates a deterministic filename using current timestamp static func generateImageFilename(problemId: String, imageIndex: Int) -> String { let timestamp = ISO8601DateFormatter().string(from: Date()) return generateImageFilename( problemId: problemId, timestamp: timestamp, imageIndex: imageIndex) } - /// Extracts problem ID from an image filename created by this utility. - /// Returns nil if the filename doesn't match our naming convention. - /// - /// - Parameter filename: The image filename - /// - Returns: The hash identifier or nil if not a valid filename + /// Extracts problem ID from an image filename static func extractProblemIdFromFilename(_ filename: String) -> String? { guard filename.hasPrefix("problem_") && filename.hasSuffix(imageExtension) else { return nil } - // Format: problem_{hash}_{index}.jpg let nameWithoutExtension = String(filename.dropLast(imageExtension.count)) let parts = nameWithoutExtension.components(separatedBy: "_") @@ -59,14 +41,10 @@ class ImageNamingUtils { return nil } - // Return the hash as identifier return parts[1] } - /// Validates if a filename follows our naming convention. - /// - /// - Parameter filename: The filename to validate - /// - Returns: true if it matches our convention, false otherwise + /// Validates if a filename follows our naming convention static func isValidImageFilename(_ filename: String) -> Bool { guard filename.hasPrefix("problem_") && filename.hasSuffix(imageExtension) else { return false @@ -79,32 +57,19 @@ class ImageNamingUtils { && Int(parts[2]) != nil } - /// Migrates an existing UUID-based filename to our naming convention. - /// This is used during sync to rename downloaded images. - /// - /// - Parameters: - /// - oldFilename: The existing filename (UUID-based) - /// - problemId: The problem ID this image belongs to - /// - imageIndex: The index of this image - /// - Returns: The new filename following our convention + /// Migrates an existing filename to our naming convention static func migrateFilename(oldFilename: String, problemId: String, imageIndex: Int) -> String { - // If it's already using our convention, keep it + if isValidImageFilename(oldFilename) { return oldFilename } - // Generate new deterministic name - // Use current timestamp to maintain some consistency let timestamp = ISO8601DateFormatter().string(from: Date()) return generateImageFilename( problemId: problemId, timestamp: timestamp, imageIndex: imageIndex) } - /// Creates a deterministic hash from input string. - /// Uses SHA-256 and takes first 12 characters for filename safety. - /// - /// - Parameter input: The input string to hash - /// - Returns: First 12 characters of SHA-256 hash in lowercase + /// Creates a deterministic hash from input string private static func createHash(from input: String) -> String { let inputData = Data(input.utf8) let hashed = SHA256.hash(data: inputData) @@ -112,13 +77,7 @@ class ImageNamingUtils { return String(hashString.prefix(hashLength)) } - /// Batch renames images for a problem to use our naming convention. - /// Returns a mapping of old filename -> new filename. - /// - /// - Parameters: - /// - problemId: The problem ID - /// - existingFilenames: List of current image filenames for this problem - /// - Returns: Dictionary mapping old filename to new filename + /// Batch renames images for a problem to use our naming convention static func batchRenameForProblem(problemId: String, existingFilenames: [String]) -> [String: String] { @@ -135,10 +94,7 @@ class ImageNamingUtils { return renameMap } - /// Validates that a collection of filenames follow our naming convention. - /// - /// - Parameter filenames: Array of filenames to validate - /// - Returns: Dictionary with validation results + /// Validates that a collection of filenames follow our naming convention static func validateFilenames(_ filenames: [String]) -> ImageValidationResult { var validImages: [String] = [] var invalidImages: [String] = [] @@ -159,7 +115,7 @@ class ImageNamingUtils { } } -/// Result of image filename validation +// Result of image filename validation struct ImageValidationResult { let totalImages: Int let validImages: [String] diff --git a/ios/OpenClimb/ViewModels/ClimbingDataManager.swift b/ios/OpenClimb/ViewModels/ClimbingDataManager.swift index b90e595..4ee65b9 100644 --- a/ios/OpenClimb/ViewModels/ClimbingDataManager.swift +++ b/ios/OpenClimb/ViewModels/ClimbingDataManager.swift @@ -554,20 +554,20 @@ class ClimbingDataManager: ObservableObject { // Collect referenced image paths let referencedImagePaths = collectReferencedImagePaths() - print("๐ŸŽฏ Starting export with \(referencedImagePaths.count) images") + print("Starting export with \(referencedImagePaths.count) images") let zipData = try ZipUtils.createExportZip( exportData: exportData, referencedImagePaths: referencedImagePaths ) - print("โœ… Export completed successfully") + print("Export completed successfully") successMessage = "Export completed with \(referencedImagePaths.count) images" clearMessageAfterDelay() return zipData } catch { let errorMessage = "Export failed: \(error.localizedDescription)" - print("โŒ \(errorMessage)") + print("ERROR: \(errorMessage)") setError(errorMessage) return nil } @@ -662,13 +662,13 @@ class ClimbingDataManager: ObservableObject { extension ClimbingDataManager { private func collectReferencedImagePaths() -> Set { var imagePaths = Set() - print("๐Ÿ–ผ๏ธ Starting image path collection...") - print("๐Ÿ“Š Total problems: \(problems.count)") + print("Starting image path collection...") + print("Total problems: \(problems.count)") for problem in problems { if !problem.imagePaths.isEmpty { print( - "๐Ÿ“ธ Problem '\(problem.name ?? "Unnamed")' has \(problem.imagePaths.count) images" + "Problem '\(problem.name ?? "Unnamed")' has \(problem.imagePaths.count) images" ) for imagePath in problem.imagePaths { print(" - Relative path: \(imagePath)") @@ -677,10 +677,10 @@ extension ClimbingDataManager { // Check if file exists if FileManager.default.fileExists(atPath: fullPath) { - print(" โœ… File exists") + print(" File exists") imagePaths.insert(fullPath) } else { - print(" โŒ File does NOT exist") + print(" File does NOT exist") // Still add it to let ZipUtils handle the error logging imagePaths.insert(fullPath) } @@ -688,7 +688,7 @@ extension ClimbingDataManager { } } - print("๐Ÿ–ผ๏ธ Collected \(imagePaths.count) total image paths for export") + print("Collected \(imagePaths.count) total image paths for export") return imagePaths } @@ -748,7 +748,7 @@ extension ClimbingDataManager { // 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" + "Image Storage: \(info.primaryCount) primary, \(info.backupCount) backup, \(info.totalSize / 1024)KB total" ) }.value } @@ -786,7 +786,7 @@ extension ClimbingDataManager { } if !orphanedFiles.isEmpty { - print("๐Ÿ—‘๏ธ Cleaned up \(orphanedFiles.count) orphaned image files") + print("Cleaned up \(orphanedFiles.count) orphaned image files") } } } @@ -803,7 +803,7 @@ extension ClimbingDataManager { } func forceImageRecovery() { - print("๐Ÿšจ User initiated force image recovery") + print("User initiated force image recovery") ImageManager.shared.forceRecoveryMigration() // Refresh the UI after recovery @@ -811,7 +811,7 @@ extension ClimbingDataManager { } func emergencyImageRestore() { - print("๐Ÿ†˜ User initiated emergency image restore") + print("User initiated emergency image restore") ImageManager.shared.emergencyImageRestore() // Refresh the UI after restore @@ -827,7 +827,7 @@ extension ClimbingDataManager { let info = ImageManager.shared.getStorageInfo() return """ - Image Storage Health: \(isValid ? "โœ… Good" : "โŒ Needs Recovery") + Image Storage Health: \(isValid ? "Good" : "Needs Recovery") Primary Files: \(info.primaryCount) Backup Files: \(info.backupCount) Total Size: \(formatBytes(info.totalSize)) @@ -845,7 +845,7 @@ extension ClimbingDataManager { // Test with dummy data if we have a gym guard let testGym = gyms.first else { - print("โŒ No gyms available for testing") + print("ERROR: No gyms available for testing") return } @@ -877,14 +877,14 @@ extension ClimbingDataManager { // Only restart if session is actually active guard activeSession.status == .active else { print( - "โš ๏ธ Session exists but is not active (status: \(activeSession.status)), ending Live Activity" + "WARNING: Session exists but is not active (status: \(activeSession.status)), ending Live Activity" ) await LiveActivityManager.shared.endLiveActivity() return } if let gym = gym(withId: activeSession.gymId) { - print("๐Ÿ” Checking Live Activity for active session at \(gym.name)") + print("Checking Live Activity for active session at \(gym.name)") // First cleanup any dismissed activities await LiveActivityManager.shared.cleanupDismissedActivities() @@ -894,15 +894,12 @@ extension ClimbingDataManager { activeSession: activeSession, gymName: gym.name ) - - // Update with current session data - await updateLiveActivityData() } } /// Call this when app becomes active to check for Live Activity restart func onAppBecomeActive() { - print("๐Ÿ“ฑ App became active - checking Live Activity status") + print("App became active - checking Live Activity status") Task { await checkAndRestartLiveActivity() } @@ -910,7 +907,7 @@ extension ClimbingDataManager { /// Call this when app enters background to update Live Activity func onAppEnterBackground() { - print("๐Ÿ“ฑ App entering background - updating Live Activity if needed") + print("App entering background - updating Live Activity if needed") Task { await updateLiveActivityData() } @@ -939,7 +936,7 @@ extension ClimbingDataManager { return } - print("๐Ÿ”„ Attempting to restart dismissed Live Activity for \(gym.name)") + print("Attempting to restart dismissed Live Activity for \(gym.name)") // Wait a bit before restarting to avoid frequency limits try? await Task.sleep(nanoseconds: 2_000_000_000) // 2 seconds @@ -979,7 +976,7 @@ extension ClimbingDataManager { activeSession.status == .active, let gym = gym(withId: activeSession.gymId) else { - print("โš ๏ธ Live Activity update skipped - no active session or gym") + print("WARNING: Live Activity update skipped - no active session or gym") if let session = activeSession { print(" Session ID: \(session.id)") print(" Session Status: \(session.status)") @@ -1003,7 +1000,7 @@ extension ClimbingDataManager { elapsedInterval = 0 } - print("๐Ÿ”„ Live Activity Update Debug:") + print("Live Activity Update Debug:") print(" Session ID: \(activeSession.id)") print(" Gym: \(gym.name)") print(" Total attempts in session: \(totalAttempts)") diff --git a/ios/OpenClimb/ViewModels/LiveActivityManager.swift b/ios/OpenClimb/ViewModels/LiveActivityManager.swift index 4f68042..980967d 100644 --- a/ios/OpenClimb/ViewModels/LiveActivityManager.swift +++ b/ios/OpenClimb/ViewModels/LiveActivityManager.swift @@ -34,11 +34,11 @@ final class LiveActivityManager { let isStillActive = activities.contains { $0.id == currentActivity.id } if isStillActive { - print("โ„น๏ธ Live Activity still running: \(currentActivity.id)") + print("Live Activity still running: \(currentActivity.id)") return } else { print( - "โš ๏ธ Tracked Live Activity \(currentActivity.id) was dismissed, clearing reference" + "WARNING: Tracked Live Activity \(currentActivity.id) was dismissed, clearing reference" ) self.currentActivity = nil } @@ -47,18 +47,18 @@ final class LiveActivityManager { // Check if there are ANY active Live Activities for this session let existingActivities = Activity.activities if let existingActivity = existingActivities.first { - print("โ„น๏ธ Found existing Live Activity: \(existingActivity.id), using it") + print("Found existing Live Activity: \(existingActivity.id), using it") self.currentActivity = existingActivity return } - print("๐Ÿ”„ No Live Activity found, restarting for existing session") + print("No Live Activity found, restarting for existing session") await startLiveActivity(for: activeSession, gymName: gymName) } /// Call this when a ClimbSession starts to begin a Live Activity func startLiveActivity(for session: ClimbSession, gymName: String) async { - print("๐Ÿ”ด Starting Live Activity for gym: \(gymName)") + print("Starting Live Activity for gym: \(gymName)") await endLiveActivity() @@ -84,9 +84,9 @@ final class LiveActivityManager { pushType: nil ) self.currentActivity = activity - print("โœ… Live Activity started successfully: \(activity.id)") + print("Live Activity started successfully: \(activity.id)") } catch { - print("โŒ Failed to start live activity: \(error)") + print("ERROR: Failed to start live activity: \(error)") print("Error details: \(error.localizedDescription)") // Check specific error types @@ -104,7 +104,7 @@ final class LiveActivityManager { func updateLiveActivity(elapsed: TimeInterval, totalAttempts: Int, completedProblems: Int) async { guard let currentActivity = currentActivity else { - print("โš ๏ธ No current activity to update") + print("WARNING: No current activity to update") return } @@ -114,14 +114,14 @@ final class LiveActivityManager { if !isStillActive { print( - "โš ๏ธ Tracked Live Activity \(currentActivity.id) is no longer active, clearing reference" + "WARNING: Tracked Live Activity \(currentActivity.id) is no longer active, clearing reference" ) self.currentActivity = nil return } print( - "๐Ÿ”„ Updating Live Activity - Attempts: \(totalAttempts), Completed: \(completedProblems)" + "Updating Live Activity - Attempts: \(totalAttempts), Completed: \(completedProblems)" ) let updatedContentState = SessionActivityAttributes.ContentState( @@ -131,7 +131,7 @@ final class LiveActivityManager { ) await currentActivity.update(.init(state: updatedContentState, staleDate: nil)) - print("โœ… Live Activity updated successfully") + print("Live Activity updated successfully") } /// Call this when a ClimbSession ends to end the Live Activity @@ -141,25 +141,25 @@ final class LiveActivityManager { // First end the tracked activity if it exists if let currentActivity { - print("๐Ÿ”ด Ending tracked Live Activity: \(currentActivity.id)") + print("Ending tracked Live Activity: \(currentActivity.id)") await currentActivity.end(nil, dismissalPolicy: .immediate) self.currentActivity = nil - print("โœ… Tracked Live Activity ended successfully") + print("Tracked Live Activity ended successfully") } // Force end ALL active activities of our type to ensure cleanup - print("๐Ÿ” Checking for any remaining active activities...") + print("Checking for any remaining active activities...") let activities = Activity.activities if activities.isEmpty { - print("โ„น๏ธ No additional activities found") + print("No additional activities found") } else { - print("๐Ÿ”ด Found \(activities.count) additional active activities, ending them...") + print("Found \(activities.count) additional active activities, ending them...") for activity in activities { - print("๐Ÿ”ด Force ending activity: \(activity.id)") + print("Force ending activity: \(activity.id)") await activity.end(nil, dismissalPolicy: .immediate) } - print("โœ… All Live Activities ended successfully") + print("All Live Activities ended successfully") } } @@ -188,7 +188,7 @@ final class LiveActivityManager { if let currentActivity = currentActivity { let isStillActive = activities.contains { $0.id == currentActivity.id } if !isStillActive { - print("๐Ÿงน Cleaning up dismissed Live Activity: \(currentActivity.id)") + print("Cleaning up dismissed Live Activity: \(currentActivity.id)") self.currentActivity = nil } } @@ -211,7 +211,7 @@ final class LiveActivityManager { func stopHealthChecks() { healthCheckTimer?.invalidate() healthCheckTimer = nil - print("๐Ÿ›‘ Stopped Live Activity health checks") + print("Stopped Live Activity health checks") } /// Perform a health check on the current Live Activity @@ -231,7 +231,7 @@ final class LiveActivityManager { let isStillActive = activities.contains { $0.id == currentActivity.id } if !isStillActive { - print("๐Ÿ’” Health check failed - Live Activity was dismissed") + print("Health check failed - Live Activity was dismissed") self.currentActivity = nil // Notify that we need to restart @@ -240,7 +240,7 @@ final class LiveActivityManager { object: nil ) } else { - print("โœ… Live Activity health check passed") + print("Live Activity health check passed") } } diff --git a/ios/OpenClimb/Views/AddEdit/AddAttemptView.swift b/ios/OpenClimb/Views/AddEdit/AddAttemptView.swift index 3c3afae..36d7a0b 100644 --- a/ios/OpenClimb/Views/AddEdit/AddAttemptView.swift +++ b/ios/OpenClimb/Views/AddEdit/AddAttemptView.swift @@ -42,7 +42,7 @@ struct AddAttemptView: View { } var body: some View { - NavigationView { + NavigationStack { Form { if !showingCreateProblem { ProblemSelectionSection() @@ -597,7 +597,7 @@ struct ProblemExpandedView: View { @State private var selectedImageIndex = 0 var body: some View { - NavigationView { + NavigationStack { ScrollView { VStack(alignment: .leading, spacing: 16) { // Images @@ -735,7 +735,7 @@ struct EditAttemptView: View { } var body: some View { - NavigationView { + NavigationStack { Form { if !showingCreateProblem { ProblemSelectionSection() diff --git a/ios/OpenClimb/Views/AddEdit/AddEditGymView.swift b/ios/OpenClimb/Views/AddEdit/AddEditGymView.swift index d0f5f69..37b7d41 100644 --- a/ios/OpenClimb/Views/AddEdit/AddEditGymView.swift +++ b/ios/OpenClimb/Views/AddEdit/AddEditGymView.swift @@ -1,4 +1,3 @@ - import SwiftUI struct AddEditGymView: View { @@ -34,7 +33,7 @@ struct AddEditGymView: View { } var body: some View { - NavigationView { + NavigationStack { Form { BasicInfoSection() ClimbTypesSection() diff --git a/ios/OpenClimb/Views/AddEdit/AddEditProblemView.swift b/ios/OpenClimb/Views/AddEdit/AddEditProblemView.swift index 78e91ec..c075edc 100644 --- a/ios/OpenClimb/Views/AddEdit/AddEditProblemView.swift +++ b/ios/OpenClimb/Views/AddEdit/AddEditProblemView.swift @@ -55,7 +55,7 @@ struct AddEditProblemView: View { } var body: some View { - NavigationView { + NavigationStack { Form { GymSelectionSection() BasicInfoSection() diff --git a/ios/OpenClimb/Views/AddEdit/AddEditSessionView.swift b/ios/OpenClimb/Views/AddEdit/AddEditSessionView.swift index 724b482..ba0df52 100644 --- a/ios/OpenClimb/Views/AddEdit/AddEditSessionView.swift +++ b/ios/OpenClimb/Views/AddEdit/AddEditSessionView.swift @@ -1,4 +1,3 @@ - import SwiftUI struct AddEditSessionView: View { @@ -21,7 +20,7 @@ struct AddEditSessionView: View { } var body: some View { - NavigationView { + NavigationStack { Form { GymSelectionSection() SessionDetailsSection() diff --git a/ios/OpenClimb/Views/AnalyticsView.swift b/ios/OpenClimb/Views/AnalyticsView.swift index fa1aac1..f1f7e1d 100644 --- a/ios/OpenClimb/Views/AnalyticsView.swift +++ b/ios/OpenClimb/Views/AnalyticsView.swift @@ -4,7 +4,7 @@ struct AnalyticsView: View { @EnvironmentObject var dataManager: ClimbingDataManager var body: some View { - NavigationView { + NavigationStack { ScrollView { LazyVStack(spacing: 20) { OverallStatsSection() diff --git a/ios/OpenClimb/Views/Detail/ProblemDetailView.swift b/ios/OpenClimb/Views/Detail/ProblemDetailView.swift index fe38fa1..a909cbb 100644 --- a/ios/OpenClimb/Views/Detail/ProblemDetailView.swift +++ b/ios/OpenClimb/Views/Detail/ProblemDetailView.swift @@ -420,7 +420,7 @@ struct ImageViewerView: View { } var body: some View { - NavigationView { + NavigationStack { TabView(selection: $currentIndex) { ForEach(imagePaths.indices, id: \.self) { index in ProblemDetailImageFullView(imagePath: imagePaths[index]) diff --git a/ios/OpenClimb/Views/Detail/SessionDetailView.swift b/ios/OpenClimb/Views/Detail/SessionDetailView.swift index 84a8e4d..383a97f 100644 --- a/ios/OpenClimb/Views/Detail/SessionDetailView.swift +++ b/ios/OpenClimb/Views/Detail/SessionDetailView.swift @@ -9,24 +9,11 @@ struct SessionDetailView: View { @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) } - private func startTimer() { - // Update every 5 seconds instead of 1 second for better performance - timer = Timer.scheduledTimer(withTimeInterval: 5.0, repeats: true) { _ in - currentTime = Date() - } - } - - private func stopTimer() { - timer?.invalidate() - timer = nil - } - private var gym: Gym? { guard let session = session else { return nil } return dataManager.gym(withId: session.gymId) @@ -47,14 +34,12 @@ struct SessionDetailView: View { calculateSessionStats() } - @State private var timer: Timer? - var body: some View { ScrollView { LazyVStack(spacing: 20) { if let session = session, let gym = gym { SessionHeaderCard( - session: session, gym: gym, stats: sessionStats, currentTime: currentTime) + session: session, gym: gym, stats: sessionStats) SessionStatsCard(stats: sessionStats) @@ -69,12 +54,7 @@ struct SessionDetailView: View { } .padding() } - .onAppear { - startTimer() - } - .onDisappear { - stopTimer() - } + .navigationTitle("Session Details") .navigationBarTitleDisplayMode(.inline) .toolbar { @@ -182,7 +162,6 @@ struct SessionHeaderCard: View { let session: ClimbSession let gym: Gym let stats: SessionStats - let currentTime: Date var body: some View { VStack(alignment: .leading, spacing: 16) { @@ -197,9 +176,13 @@ struct SessionHeaderCard: View { if session.status == .active { if let startTime = session.startTime { - Text("Duration: \(formatDuration(from: startTime, to: currentTime))") + Text("Duration: ") .font(.subheadline) .foregroundColor(.secondary) + + Text(timerInterval: startTime...Date.distantFuture, countsDown: false) + .font(.subheadline) + .foregroundColor(.secondary) + .monospacedDigit() } } else if let duration = session.duration { Text("Duration: \(duration) minutes") @@ -246,20 +229,6 @@ struct SessionHeaderCard: View { 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 { diff --git a/ios/OpenClimb/Views/GymsView.swift b/ios/OpenClimb/Views/GymsView.swift index 3937ae5..3c4d72e 100644 --- a/ios/OpenClimb/Views/GymsView.swift +++ b/ios/OpenClimb/Views/GymsView.swift @@ -5,7 +5,7 @@ struct GymsView: View { @State private var showingAddGym = false var body: some View { - NavigationView { + NavigationStack { VStack { if dataManager.gyms.isEmpty { EmptyGymsView() diff --git a/ios/OpenClimb/Views/LiveActivityDebugView.swift b/ios/OpenClimb/Views/LiveActivityDebugView.swift index 5d75989..cd7c8fd 100644 --- a/ios/OpenClimb/Views/LiveActivityDebugView.swift +++ b/ios/OpenClimb/Views/LiveActivityDebugView.swift @@ -9,7 +9,7 @@ struct LiveActivityDebugView: View { @State private var isTestRunning = false var body: some View { - NavigationView { + NavigationStack { VStack(alignment: .leading, spacing: 20) { // Header @@ -87,7 +87,7 @@ struct LiveActivityDebugView: View { .disabled(dataManager.activeSession == nil) if dataManager.gyms.isEmpty { - Text("โš ๏ธ Add at least one gym to test Live Activities") + Text("WARNING: Add at least one gym to test Live Activities") .font(.caption) .foregroundColor(.orange) } @@ -167,29 +167,31 @@ struct LiveActivityDebugView: View { } private func checkStatus() { - appendDebugOutput("๐Ÿ” Checking Live Activity status...") + appendDebugOutput("Checking Live Activity status...") let status = LiveActivityManager.shared.checkLiveActivityAvailability() appendDebugOutput("Status: \(status)") // Check iOS version if #available(iOS 16.1, *) { - appendDebugOutput("โœ… iOS version supports Live Activities") + appendDebugOutput("iOS version supports Live Activities") } else { - appendDebugOutput("โŒ iOS version does not support Live Activities (requires 16.1+)") + appendDebugOutput( + "ERROR: iOS version does not support Live Activities (requires 16.1+)") } // Check if we're on simulator #if targetEnvironment(simulator) - appendDebugOutput("โš ๏ธ Running on Simulator - Live Activities have limited functionality") + appendDebugOutput( + "WARNING: Running on Simulator - Live Activities have limited functionality") #else - appendDebugOutput("โœ… Running on device - Live Activities should work fully") + appendDebugOutput("Running on device - Live Activities should work fully") #endif } private func testLiveActivity() { guard !dataManager.gyms.isEmpty else { - appendDebugOutput("โŒ No gyms available for testing") + appendDebugOutput("ERROR: No gyms available for testing") return } @@ -240,25 +242,25 @@ struct LiveActivityDebugView: View { appendDebugOutput("Ending Live Activity...") await LiveActivityManager.shared.endLiveActivity() - appendDebugOutput("๐Ÿ Live Activity test completed!") + appendDebugOutput("Live Activity test completed!") } } private func endCurrentSession() { guard let activeSession = dataManager.activeSession else { - appendDebugOutput("โŒ No active session to end") + appendDebugOutput("ERROR: No active session to end") return } - appendDebugOutput("๐Ÿ›‘ Ending current session: \(activeSession.id)") + appendDebugOutput("Ending current session: \(activeSession.id)") dataManager.endSession(activeSession.id) - appendDebugOutput("โœ… Session ended") + appendDebugOutput("Session ended") } private func forceLiveActivityUpdate() { - appendDebugOutput("๐Ÿ”„ Forcing Live Activity update...") + appendDebugOutput("Forcing Live Activity update...") dataManager.forceLiveActivityUpdate() - appendDebugOutput("โœ… Live Activity update sent") + appendDebugOutput("Live Activity update sent") } } diff --git a/ios/OpenClimb/Views/ProblemsView.swift b/ios/OpenClimb/Views/ProblemsView.swift index a4458f4..59b41a6 100644 --- a/ios/OpenClimb/Views/ProblemsView.swift +++ b/ios/OpenClimb/Views/ProblemsView.swift @@ -6,6 +6,8 @@ struct ProblemsView: View { @State private var selectedClimbType: ClimbType? @State private var selectedGym: Gym? @State private var searchText = "" + @State private var showingSearch = false + @FocusState private var isSearchFocused: Bool private var filteredProblems: [Problem] { var filtered = dataManager.problems @@ -38,29 +40,67 @@ struct ProblemsView: View { } var body: some View { - NavigationView { - VStack(spacing: 0) { - if !dataManager.problems.isEmpty { - FilterSection( - selectedClimbType: $selectedClimbType, - selectedGym: $selectedGym, - filteredProblems: filteredProblems - ) - .padding() - .background(.regularMaterial) - } + NavigationStack { + Group { + VStack(spacing: 0) { + if showingSearch { + HStack(spacing: 8) { + Image(systemName: "magnifyingglass") + .foregroundColor(.secondary) + .font(.system(size: 16, weight: .medium)) - if filteredProblems.isEmpty { - EmptyProblemsView( - isEmpty: dataManager.problems.isEmpty, - isFiltered: !dataManager.problems.isEmpty - ) - } else { - ProblemsList(problems: filteredProblems) + TextField("Search problems...", text: $searchText) + .textFieldStyle(.plain) + .font(.system(size: 16)) + .focused($isSearchFocused) + .submitLabel(.search) + } + .padding(.horizontal, 12) + .padding(.vertical, 10) + .background { + if #available(iOS 18.0, *) { + RoundedRectangle(cornerRadius: 12) + .fill(.regularMaterial) + .overlay { + RoundedRectangle(cornerRadius: 12) + .stroke(.quaternary, lineWidth: 0.5) + } + } else { + RoundedRectangle(cornerRadius: 10) + .fill(Color(.systemGray6)) + .overlay { + RoundedRectangle(cornerRadius: 10) + .stroke(Color(.systemGray4), lineWidth: 0.5) + } + } + } + .padding(.horizontal) + .padding(.top, 8) + .animation(.easeInOut(duration: 0.3), value: showingSearch) + } + + if !dataManager.problems.isEmpty && !showingSearch { + FilterSection( + selectedClimbType: $selectedClimbType, + selectedGym: $selectedGym, + filteredProblems: filteredProblems + ) + .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...") + .navigationBarTitleDisplayMode(.automatic) .toolbar { ToolbarItemGroup(placement: .navigationBarTrailing) { if dataManager.isSyncing { @@ -81,6 +121,22 @@ struct ProblemsView: View { ) } + Button(action: { + withAnimation(.easeInOut(duration: 0.3)) { + showingSearch.toggle() + if showingSearch { + isSearchFocused = true + } else { + searchText = "" + isSearchFocused = false + } + } + }) { + Image(systemName: showingSearch ? "xmark.circle.fill" : "magnifyingglass") + .font(.system(size: 16, weight: .medium)) + .foregroundColor(showingSearch ? .secondary : .blue) + } + if !dataManager.gyms.isEmpty { Button("Add") { showingAddProblem = true diff --git a/ios/OpenClimb/Views/SessionsView.swift b/ios/OpenClimb/Views/SessionsView.swift index 898d458..3852036 100644 --- a/ios/OpenClimb/Views/SessionsView.swift +++ b/ios/OpenClimb/Views/SessionsView.swift @@ -6,7 +6,7 @@ struct SessionsView: View { @State private var showingAddSession = false var body: some View { - NavigationView { + NavigationStack { Group { if dataManager.sessions.isEmpty && dataManager.activeSession == nil { EmptySessionsView() @@ -53,7 +53,6 @@ struct SessionsView: View { AddEditSessionView() } } - .navigationViewStyle(.stack) } } @@ -129,11 +128,8 @@ struct ActiveSessionBanner: View { let session: ClimbSession let gym: Gym @EnvironmentObject var dataManager: ClimbingDataManager - @State private var currentTime = Date() @State private var navigateToDetail = false - @State private var timer: Timer? - var body: some View { HStack { VStack(alignment: .leading, spacing: 4) { @@ -151,9 +147,10 @@ struct ActiveSessionBanner: View { .foregroundColor(.secondary) if let startTime = session.startTime { - Text(formatDuration(from: startTime, to: currentTime)) + Text(timerInterval: startTime...Date.distantFuture, countsDown: false) .font(.caption) .foregroundColor(.secondary) + .monospacedDigit() } } .frame(maxWidth: .infinity, alignment: .leading) @@ -180,42 +177,12 @@ struct ActiveSessionBanner: View { .fill(.green.opacity(0.1)) .stroke(.green.opacity(0.3), lineWidth: 1) ) - .onAppear { - startTimer() - } - .onDisappear { - stopTimer() - } + .navigationDestination(isPresented: $navigateToDetail) { SessionDetailView(sessionId: session.id) } } - 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) - } - } - - private func startTimer() { - timer = Timer.scheduledTimer(withTimeInterval: 5.0, repeats: true) { _ in - currentTime = Date() - } - } - - private func stopTimer() { - timer?.invalidate() - timer = nil - } } struct SessionRow: View { diff --git a/ios/OpenClimb/Views/SettingsView.swift b/ios/OpenClimb/Views/SettingsView.swift index cd55dd4..746c972 100644 --- a/ios/OpenClimb/Views/SettingsView.swift +++ b/ios/OpenClimb/Views/SettingsView.swift @@ -11,49 +11,52 @@ struct SettingsView: View { @State private var activeSheet: SheetType? var body: some View { - List { - SyncSection() - .environmentObject(dataManager.syncService) + NavigationStack { + List { + SyncSection() + .environmentObject(dataManager.syncService) - DataManagementSection( - activeSheet: $activeSheet - ) + DataManagementSection( + activeSheet: $activeSheet + ) - AppInfoSection() - } - .navigationTitle("Settings") - .toolbar { - ToolbarItem(placement: .navigationBarTrailing) { - if dataManager.isSyncing { - HStack(spacing: 2) { - ProgressView() - .progressViewStyle(CircularProgressViewStyle(tint: .blue)) - .scaleEffect(0.6) + AppInfoSection() + } + .navigationTitle("Settings") + .navigationBarTitleDisplayMode(.automatic) + .toolbar { + ToolbarItem(placement: .navigationBarTrailing) { + if dataManager.isSyncing { + HStack(spacing: 2) { + ProgressView() + .progressViewStyle(CircularProgressViewStyle(tint: .blue)) + .scaleEffect(0.6) + } + .padding(.horizontal, 6) + .padding(.vertical, 3) + .background( + Circle() + .fill(.regularMaterial) + ) + .transition(.scale.combined(with: .opacity)) + .animation( + .easeInOut(duration: 0.2), value: dataManager.isSyncing + ) } - .padding(.horizontal, 6) - .padding(.vertical, 3) - .background( - Circle() - .fill(.regularMaterial) - ) - .transition(.scale.combined(with: .opacity)) - .animation( - .easeInOut(duration: 0.2), value: dataManager.isSyncing - ) } } - } - .sheet( - item: Binding( - get: { activeSheet }, - set: { activeSheet = $0 } - ) - ) { sheetType in - switch sheetType { - case .export(let data): - ExportDataView(data: data) - case .importData: - ImportDataView() + .sheet( + item: Binding( + get: { activeSheet }, + set: { activeSheet = $0 } + ) + ) { sheetType in + switch sheetType { + case .export(let data): + ExportDataView(data: data) + case .importData: + ImportDataView() + } } } } @@ -191,7 +194,7 @@ struct ExportDataView: View { @State private var isCreatingFile = true var body: some View { - NavigationView { + NavigationStack { VStack(spacing: 30) { if isCreatingFile { // Loading state - more prominent @@ -498,7 +501,7 @@ struct SyncSettingsView: View { @State private var testResultMessage = "" var body: some View { - NavigationView { + NavigationStack { Form { Section { TextField("Server URL", text: $serverURL) @@ -691,7 +694,7 @@ struct ImportDataView: View { @State private var showingDocumentPicker = false var body: some View { - NavigationView { + NavigationStack { VStack(spacing: 20) { Image(systemName: "square.and.arrow.down") .font(.system(size: 60)) @@ -705,7 +708,7 @@ struct ImportDataView: View { Text("Import climbing data from a previously exported ZIP file.") .multilineTextAlignment(.center) - Text("โš ๏ธ Warning: This will replace all current data!") + Text("WARNING: This will replace all current data!") .font(.subheadline) .foregroundColor(.red) .multilineTextAlignment(.center) diff --git a/ios/OpenClimbTests/OpenClimbTests.swift b/ios/OpenClimbTests/OpenClimbTests.swift new file mode 100644 index 0000000..84f3b66 --- /dev/null +++ b/ios/OpenClimbTests/OpenClimbTests.swift @@ -0,0 +1,255 @@ +import XCTest + +final class OpenClimbTests: XCTestCase { + + override func setUpWithError() throws { + } + + override func tearDownWithError() throws { + } + + // MARK: - Data Validation Tests + + func testDifficultyGradeComparison() throws { + // Test basic difficulty grade string comparison + let grade1 = "V5" + let grade2 = "V3" + let grade3 = "V5" + + XCTAssertEqual(grade1, grade3) + XCTAssertNotEqual(grade1, grade2) + XCTAssertFalse(grade1.isEmpty) + } + + func testClimbTypeValidation() throws { + // Test climb type validation + let validClimbTypes = ["ROPE", "BOULDER"] + + for climbType in validClimbTypes { + XCTAssertTrue(validClimbTypes.contains(climbType)) + XCTAssertFalse(climbType.isEmpty) + } + + let invalidTypes = ["", "unknown", "invalid", "sport", "trad", "toprope"] + for invalidType in invalidTypes { + if !invalidType.isEmpty { + XCTAssertFalse(validClimbTypes.contains(invalidType)) + } + } + } + + func testDateFormatting() throws { + // Test ISO 8601 date formatting + let formatter = ISO8601DateFormatter() + let date = Date() + let formattedDate = formatter.string(from: date) + + XCTAssertFalse(formattedDate.isEmpty) + XCTAssertTrue(formattedDate.contains("T")) + XCTAssertTrue(formattedDate.hasSuffix("Z")) + + // Test parsing back + let parsedDate = formatter.date(from: formattedDate) + XCTAssertNotNil(parsedDate) + } + + func testSessionDurationCalculation() throws { + // Test session duration calculation + let startTime = Date() + let endTime = Date(timeInterval: 3600, since: startTime) // 1 hour later + let duration = endTime.timeIntervalSince(startTime) + + XCTAssertEqual(duration, 3600, accuracy: 1.0) + XCTAssertGreaterThan(duration, 0) + } + + func testAttemptResultValidation() throws { + // Test attempt result validation + let validResults = ["completed", "failed", "flash", "project"] + + for result in validResults { + XCTAssertTrue(validResults.contains(result)) + XCTAssertFalse(result.isEmpty) + } + } + + func testGymCreation() throws { + // Test gym model creation with basic validation + let gymName = "Test Climbing Gym" + let location = "Test City" + let supportedTypes = ["BOULDER", "ROPE"] + + XCTAssertFalse(gymName.isEmpty) + XCTAssertFalse(location.isEmpty) + XCTAssertFalse(supportedTypes.isEmpty) + XCTAssertEqual(supportedTypes.count, 2) + XCTAssertTrue(supportedTypes.contains("BOULDER")) + XCTAssertTrue(supportedTypes.contains("ROPE")) + } + + func testProblemValidation() throws { + // Test problem model validation + let problemName = "Test Problem" + let climbType = "BOULDER" + let difficulty = "V5" + let tags = ["overhang", "crimpy"] + + XCTAssertFalse(problemName.isEmpty) + XCTAssertTrue(["BOULDER", "ROPE"].contains(climbType)) + XCTAssertFalse(difficulty.isEmpty) + XCTAssertEqual(tags.count, 2) + XCTAssertTrue(tags.allSatisfy { !$0.isEmpty }) + } + + func testSessionStatusTransitions() throws { + // Test session status transitions + let validStatuses = ["planned", "active", "completed", "cancelled"] + + for status in validStatuses { + XCTAssertTrue(validStatuses.contains(status)) + XCTAssertFalse(status.isEmpty) + } + + // Test status transitions logic + let initialStatus = "planned" + let activeStatus = "active" + let completedStatus = "completed" + + XCTAssertNotEqual(initialStatus, activeStatus) + XCTAssertNotEqual(activeStatus, completedStatus) + } + + func testUniqueIDGeneration() throws { + // Test unique ID generation using UUID + let id1 = UUID().uuidString + let id2 = UUID().uuidString + + XCTAssertNotEqual(id1, id2) + XCTAssertFalse(id1.isEmpty) + XCTAssertFalse(id2.isEmpty) + XCTAssertEqual(id1.count, 36) // UUID string length + XCTAssertTrue(id1.contains("-")) + } + + func testDataValidation() throws { + // Test basic data validation patterns + let emptyString = "" + let validString = "test" + let negativeNumber = -1 + let positiveNumber = 5 + let zeroNumber = 0 + + XCTAssertTrue(emptyString.isEmpty) + XCTAssertFalse(validString.isEmpty) + XCTAssertLessThan(negativeNumber, 0) + XCTAssertGreaterThan(positiveNumber, 0) + XCTAssertEqual(zeroNumber, 0) + } + + // MARK: - Collection Tests + + func testArrayOperations() throws { + // Test array operations for climb data + var problems: [String] = [] + + XCTAssertTrue(problems.isEmpty) + XCTAssertEqual(problems.count, 0) + + problems.append("Problem 1") + problems.append("Problem 2") + + XCTAssertFalse(problems.isEmpty) + XCTAssertEqual(problems.count, 2) + XCTAssertTrue(problems.contains("Problem 1")) + + let filteredProblems = problems.filter { $0.contains("1") } + XCTAssertEqual(filteredProblems.count, 1) + } + + func testDictionaryOperations() throws { + // Test dictionary operations for data storage + var gymData: [String: Any] = [:] + + XCTAssertTrue(gymData.isEmpty) + + gymData["name"] = "Test Gym" + gymData["location"] = "Test City" + gymData["types"] = ["BOULDER", "ROPE"] + + XCTAssertFalse(gymData.isEmpty) + XCTAssertEqual(gymData.count, 3) + XCTAssertNotNil(gymData["name"]) + + if let name = gymData["name"] as? String { + XCTAssertEqual(name, "Test Gym") + } else { + XCTFail("Failed to cast gym name to String") + } + } + + // MARK: - String and Numeric Tests + + func testStringManipulation() throws { + // Test string operations common in climb data + let problemName = " Test Problem V5 " + let trimmedName = problemName.trimmingCharacters(in: .whitespacesAndNewlines) + let uppercaseName = trimmedName.uppercased() + let lowercaseName = trimmedName.lowercased() + + XCTAssertEqual(trimmedName, "Test Problem V5") + XCTAssertEqual(uppercaseName, "TEST PROBLEM V5") + XCTAssertEqual(lowercaseName, "test problem v5") + + let components = trimmedName.components(separatedBy: " ") + XCTAssertEqual(components.count, 3) + XCTAssertEqual(components.last, "V5") + } + + func testNumericOperations() throws { + // Test numeric operations for climb ratings and statistics + let grades = [3, 5, 7, 4, 6] + let sum = grades.reduce(0, +) + let average = Double(sum) / Double(grades.count) + let maxGrade = grades.max() ?? 0 + let minGrade = grades.min() ?? 0 + + XCTAssertEqual(sum, 25) + XCTAssertEqual(average, 5.0, accuracy: 0.01) + XCTAssertEqual(maxGrade, 7) + XCTAssertEqual(minGrade, 3) + } + + // MARK: - JSON and Data Format Tests + + func testJSONSerialization() throws { + // Test JSON serialization for basic data structures + let testData: [String: Any] = [ + "id": "test123", + "name": "Test Gym", + "active": true, + "rating": 4.5, + "types": ["BOULDER", "ROPE"], + ] + + XCTAssertNoThrow({ + let jsonData = try JSONSerialization.data(withJSONObject: testData) + XCTAssertFalse(jsonData.isEmpty) + + let deserializedData = + try JSONSerialization.jsonObject(with: jsonData) as? [String: Any] + XCTAssertNotNil(deserializedData) + XCTAssertEqual(deserializedData?["name"] as? String, "Test Gym") + }) + } + + func testDateSerialization() throws { + // Test date serialization for API compatibility + let date = Date() + let formatter = ISO8601DateFormatter() + let dateString = formatter.string(from: date) + let parsedDate = formatter.date(from: dateString) + + XCTAssertNotNil(parsedDate) + XCTAssertEqual(date.timeIntervalSince1970, parsedDate!.timeIntervalSince1970, accuracy: 1.0) + } +} diff --git a/sync/format_test.go b/sync/format_test.go new file mode 100644 index 0000000..a768cfa --- /dev/null +++ b/sync/format_test.go @@ -0,0 +1,479 @@ +package main + +import ( + "encoding/json" + "strings" + "testing" +) + +func TestDataFormatCompatibility(t *testing.T) { + t.Run("JSON Marshaling and Unmarshaling", func(t *testing.T) { + originalBackup := ClimbDataBackup{ + ExportedAt: "2024-01-01T10:00:00Z", + Version: "2.0", + FormatVersion: "2.0", + Gyms: []BackupGym{ + { + ID: "gym1", + Name: "Test Gym", + Location: stringPtr("Test Location"), + SupportedClimbTypes: []string{"BOULDER", "ROPE"}, + DifficultySystems: []string{"V", "YDS"}, + CustomDifficultyGrades: []string{"V0+", "V1+"}, + Notes: stringPtr("Test notes"), + CreatedAt: "2024-01-01T10:00:00Z", + UpdatedAt: "2024-01-01T10:00:00Z", + }, + }, + Problems: []BackupProblem{ + { + ID: "problem1", + GymID: "gym1", + Name: stringPtr("Test Problem"), + Description: stringPtr("A challenging problem"), + ClimbType: "BOULDER", + Difficulty: DifficultyGrade{ + System: "V", + Grade: "V5", + NumericValue: 5, + }, + Tags: []string{"overhang", "crimpy"}, + Location: stringPtr("Wall A"), + ImagePaths: []string{"image1.jpg", "image2.jpg"}, + IsActive: true, + DateSet: stringPtr("2024-01-01"), + Notes: stringPtr("Watch the start"), + CreatedAt: "2024-01-01T10:00:00Z", + UpdatedAt: "2024-01-01T10:00:00Z", + }, + }, + Sessions: []BackupClimbSession{ + { + ID: "session1", + GymID: "gym1", + Date: "2024-01-01", + StartTime: stringPtr("2024-01-01T10:00:00Z"), + EndTime: stringPtr("2024-01-01T12:00:00Z"), + Duration: int64Ptr(7200), + Status: "completed", + Notes: stringPtr("Great session"), + CreatedAt: "2024-01-01T10:00:00Z", + UpdatedAt: "2024-01-01T12:00:00Z", + }, + }, + Attempts: []BackupAttempt{ + { + ID: "attempt1", + SessionID: "session1", + ProblemID: "problem1", + Result: "completed", + HighestHold: stringPtr("Top"), + Notes: stringPtr("Clean send"), + Duration: int64Ptr(300), + RestTime: int64Ptr(120), + Timestamp: "2024-01-01T10:30:00Z", + CreatedAt: "2024-01-01T10:30:00Z", + }, + }, + } + + jsonData, err := json.Marshal(originalBackup) + if err != nil { + t.Fatalf("Failed to marshal backup: %v", err) + } + + var unmarshaledBackup ClimbDataBackup + if err := json.Unmarshal(jsonData, &unmarshaledBackup); err != nil { + t.Fatalf("Failed to unmarshal backup: %v", err) + } + + if originalBackup.Version != unmarshaledBackup.Version { + t.Errorf("Version mismatch: expected %s, got %s", originalBackup.Version, unmarshaledBackup.Version) + } + + if len(originalBackup.Gyms) != len(unmarshaledBackup.Gyms) { + t.Errorf("Gyms count mismatch: expected %d, got %d", len(originalBackup.Gyms), len(unmarshaledBackup.Gyms)) + } + + if len(originalBackup.Problems) != len(unmarshaledBackup.Problems) { + t.Errorf("Problems count mismatch: expected %d, got %d", len(originalBackup.Problems), len(unmarshaledBackup.Problems)) + } + + if len(originalBackup.Sessions) != len(unmarshaledBackup.Sessions) { + t.Errorf("Sessions count mismatch: expected %d, got %d", len(originalBackup.Sessions), len(unmarshaledBackup.Sessions)) + } + + if len(originalBackup.Attempts) != len(unmarshaledBackup.Attempts) { + t.Errorf("Attempts count mismatch: expected %d, got %d", len(originalBackup.Attempts), len(unmarshaledBackup.Attempts)) + } + }) + + t.Run("Required Fields Validation", func(t *testing.T) { + testCases := []struct { + name string + jsonInput string + shouldError bool + }{ + { + name: "Valid minimal backup", + jsonInput: `{ + "exportedAt": "2024-01-01T10:00:00Z", + "version": "2.0", + "formatVersion": "2.0", + "gyms": [], + "problems": [], + "sessions": [], + "attempts": [] + }`, + shouldError: false, + }, + { + name: "Missing version field", + jsonInput: `{ + "exportedAt": "2024-01-01T10:00:00Z", + "formatVersion": "2.0", + "gyms": [], + "problems": [], + "sessions": [], + "attempts": [] + }`, + shouldError: false, + }, + { + name: "Invalid JSON structure", + jsonInput: `{ + "exportedAt": "2024-01-01T10:00:00Z", + "version": "2.0", + "formatVersion": "2.0", + "gyms": "not an array" + }`, + shouldError: true, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + var backup ClimbDataBackup + err := json.Unmarshal([]byte(tc.jsonInput), &backup) + + if tc.shouldError && err == nil { + t.Error("Expected error but got none") + } + + if !tc.shouldError && err != nil { + t.Errorf("Unexpected error: %v", err) + } + }) + } + }) + + t.Run("Difficulty Grade Format", func(t *testing.T) { + testGrades := []DifficultyGrade{ + {System: "V", Grade: "V0", NumericValue: 0}, + {System: "V", Grade: "V5", NumericValue: 5}, + {System: "V", Grade: "V10", NumericValue: 10}, + {System: "YDS", Grade: "5.10a", NumericValue: 100}, + {System: "YDS", Grade: "5.12d", NumericValue: 124}, + {System: "Font", Grade: "6A", NumericValue: 60}, + {System: "Custom", Grade: "Beginner", NumericValue: 1}, + } + + for _, grade := range testGrades { + jsonData, err := json.Marshal(grade) + if err != nil { + t.Errorf("Failed to marshal grade %+v: %v", grade, err) + continue + } + + var unmarshaledGrade DifficultyGrade + if err := json.Unmarshal(jsonData, &unmarshaledGrade); err != nil { + t.Errorf("Failed to unmarshal grade %s: %v", string(jsonData), err) + continue + } + + if grade.System != unmarshaledGrade.System { + t.Errorf("System mismatch for grade %+v: expected %s, got %s", grade, grade.System, unmarshaledGrade.System) + } + + if grade.Grade != unmarshaledGrade.Grade { + t.Errorf("Grade mismatch for grade %+v: expected %s, got %s", grade, grade.Grade, unmarshaledGrade.Grade) + } + + if grade.NumericValue != unmarshaledGrade.NumericValue { + t.Errorf("NumericValue mismatch for grade %+v: expected %d, got %d", grade, grade.NumericValue, unmarshaledGrade.NumericValue) + } + } + }) + + t.Run("Null and Optional Fields", func(t *testing.T) { + jsonWithNulls := `{ + "exportedAt": "2024-01-01T10:00:00Z", + "version": "2.0", + "formatVersion": "2.0", + "gyms": [{ + "id": "gym1", + "name": "Test Gym", + "location": null, + "supportedClimbTypes": ["boulder"], + "difficultySystems": ["V"], + "customDifficultyGrades": [], + "notes": null, + "createdAt": "2024-01-01T10:00:00Z", + "updatedAt": "2024-01-01T10:00:00Z" + }], + "problems": [{ + "id": "problem1", + "gymId": "gym1", + "name": null, + "description": null, + "climbType": "boulder", + "difficulty": { + "system": "V", + "grade": "V5", + "numericValue": 5 + }, + "tags": [], + "location": null, + "imagePaths": [], + "isActive": true, + "dateSet": null, + "notes": null, + "createdAt": "2024-01-01T10:00:00Z", + "updatedAt": "2024-01-01T10:00:00Z" + }], + "sessions": [], + "attempts": [] + }` + + var backup ClimbDataBackup + if err := json.Unmarshal([]byte(jsonWithNulls), &backup); err != nil { + t.Fatalf("Failed to unmarshal JSON with nulls: %v", err) + } + + if backup.Gyms[0].Location != nil { + t.Error("Expected location to be nil") + } + + if backup.Gyms[0].Notes != nil { + t.Error("Expected notes to be nil") + } + + if backup.Problems[0].Name != nil { + t.Error("Expected problem name to be nil") + } + }) + + t.Run("Date Format Validation", func(t *testing.T) { + validDates := []string{ + "2024-01-01T10:00:00Z", + "2024-12-31T23:59:59Z", + "2024-06-15T12:30:45Z", + "2024-01-01T00:00:00Z", + } + + invalidDates := []string{ + "2024-01-01 10:00:00", + "2024/01/01T10:00:00Z", + "2024-1-1T10:00:00Z", + } + + for _, date := range validDates { + if !isValidISODate(date) { + t.Errorf("Valid date %s was marked as invalid", date) + } + } + + for _, date := range invalidDates { + if isValidISODate(date) { + t.Errorf("Invalid date %s was marked as valid", date) + } + } + }) + + t.Run("Field Length Limits", func(t *testing.T) { + longString := strings.Repeat("a", 10000) + + gym := BackupGym{ + ID: "gym1", + Name: longString, + Location: &longString, + SupportedClimbTypes: []string{"boulder"}, + DifficultySystems: []string{"V"}, + CustomDifficultyGrades: []string{}, + Notes: &longString, + CreatedAt: "2024-01-01T10:00:00Z", + UpdatedAt: "2024-01-01T10:00:00Z", + } + + jsonData, err := json.Marshal(gym) + if err != nil { + t.Errorf("Failed to marshal gym with long strings: %v", err) + } + + var unmarshaledGym BackupGym + if err := json.Unmarshal(jsonData, &unmarshaledGym); err != nil { + t.Errorf("Failed to unmarshal gym with long strings: %v", err) + } + + if unmarshaledGym.Name != longString { + t.Error("Long name was not preserved") + } + }) + + t.Run("Array Field Validation", func(t *testing.T) { + backup := ClimbDataBackup{ + ExportedAt: "2024-01-01T10:00:00Z", + Version: "2.0", + FormatVersion: "2.0", + Gyms: nil, + Problems: []BackupProblem{}, + Sessions: []BackupClimbSession{}, + Attempts: []BackupAttempt{}, + } + + jsonData, err := json.Marshal(backup) + if err != nil { + t.Fatalf("Failed to marshal backup with nil gyms: %v", err) + } + + var unmarshaledBackup ClimbDataBackup + if err := json.Unmarshal(jsonData, &unmarshaledBackup); err != nil { + t.Fatalf("Failed to unmarshal backup with nil gyms: %v", err) + } + + if len(unmarshaledBackup.Gyms) != 0 { + t.Error("Expected gyms to be empty or nil") + } + }) +} + +func isValidISODate(date string) bool { + // More robust ISO date validation + if !strings.Contains(date, "T") || !strings.HasSuffix(date, "Z") { + return false + } + + // Check basic format: YYYY-MM-DDTHH:MM:SSZ + parts := strings.Split(date, "T") + if len(parts) != 2 { + return false + } + + datePart := parts[0] + timePart := strings.TrimSuffix(parts[1], "Z") + + // Date part should be YYYY-MM-DD + dateComponents := strings.Split(datePart, "-") + if len(dateComponents) != 3 || len(dateComponents[0]) != 4 || len(dateComponents[1]) != 2 || len(dateComponents[2]) != 2 { + return false + } + + // Time part should be HH:MM:SS + timeComponents := strings.Split(timePart, ":") + if len(timeComponents) != 3 || len(timeComponents[0]) != 2 || len(timeComponents[1]) != 2 || len(timeComponents[2]) != 2 { + return false + } + + return true +} + +func TestVersionCompatibility(t *testing.T) { + testCases := []struct { + version string + formatVersion string + shouldSupport bool + }{ + {"2.0", "2.0", true}, + {"1.0", "1.0", true}, + {"2.1", "2.0", false}, + {"3.0", "2.0", false}, + {"1.0", "2.0", false}, + } + + for _, tc := range testCases { + t.Run(tc.version+"/"+tc.formatVersion, func(t *testing.T) { + backup := ClimbDataBackup{ + Version: tc.version, + FormatVersion: tc.formatVersion, + } + + // Only exact version matches are supported for now + isSupported := backup.Version == "2.0" && backup.FormatVersion == "2.0" + if backup.Version == "1.0" && backup.FormatVersion == "1.0" { + isSupported = true + } + + if isSupported != tc.shouldSupport { + t.Errorf("Version %s support expectation mismatch: expected %v, got %v", + tc.version, tc.shouldSupport, isSupported) + } + }) + } +} + +func TestClimbTypeValidation(t *testing.T) { + validClimbTypes := []string{"boulder", "sport", "trad", "toprope", "aid", "ice", "mixed"} + invalidClimbTypes := []string{"", "invalid", "BOULDER", "Sport", "unknown"} + + for _, climbType := range validClimbTypes { + if !isValidClimbType(climbType) { + t.Errorf("Valid climb type %s was marked as invalid", climbType) + } + } + + for _, climbType := range invalidClimbTypes { + if isValidClimbType(climbType) { + t.Errorf("Invalid climb type %s was marked as valid", climbType) + } + } +} + +func isValidClimbType(climbType string) bool { + validTypes := map[string]bool{ + "boulder": true, + "sport": true, + "trad": true, + "toprope": true, + "aid": true, + "ice": true, + "mixed": true, + } + return validTypes[climbType] +} + +func TestAttemptResultValidation(t *testing.T) { + validResults := []string{"completed", "failed", "flash", "project", "attempt"} + invalidResults := []string{"", "invalid", "COMPLETED", "Failed", "unknown"} + + for _, result := range validResults { + if !isValidAttemptResult(result) { + t.Errorf("Valid attempt result %s was marked as invalid", result) + } + } + + for _, result := range invalidResults { + if isValidAttemptResult(result) { + t.Errorf("Invalid attempt result %s was marked as valid", result) + } + } +} + +// Helper functions for creating pointers +func stringPtr(s string) *string { + return &s +} + +func int64Ptr(i int64) *int64 { + return &i +} + +func isValidAttemptResult(result string) bool { + validResults := map[string]bool{ + "completed": true, + "failed": true, + "flash": true, + "project": true, + "attempt": true, + } + return validResults[result] +} diff --git a/sync/go.mod b/sync/go.mod index 3103696..44618d1 100644 --- a/sync/go.mod +++ b/sync/go.mod @@ -1,3 +1,3 @@ module openclimb-sync -go 1.25 +go 1.21 diff --git a/sync/main_test.go b/sync/main_test.go new file mode 100644 index 0000000..45fd17f --- /dev/null +++ b/sync/main_test.go @@ -0,0 +1,361 @@ +package main + +import ( + "encoding/json" + "path/filepath" + "strings" + "testing" + "time" +) + +func TestSyncServerAuthentication(t *testing.T) { + server := &SyncServer{authToken: "test-token"} + + tests := []struct { + name string + token string + expected bool + }{ + {"Valid token", "test-token", true}, + {"Invalid token", "wrong-token", false}, + {"Empty token", "", false}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + // Test the authentication logic directly without HTTP + result := strings.Compare(tt.token, server.authToken) == 0 + if result != tt.expected { + t.Errorf("authenticate() = %v, want %v", result, tt.expected) + } + }) + } +} + +func TestLoadDataNonExistentFile(t *testing.T) { + tempDir := t.TempDir() + server := &SyncServer{ + dataFile: filepath.Join(tempDir, "nonexistent.json"), + } + + backup, err := server.loadData() + if err != nil { + t.Errorf("loadData() error = %v, want nil", err) + } + + if backup == nil { + t.Error("Expected backup to be non-nil") + } + + if len(backup.Gyms) != 0 || len(backup.Problems) != 0 || len(backup.Sessions) != 0 || len(backup.Attempts) != 0 { + t.Error("Expected empty backup data") + } + + if backup.Version != "2.0" || backup.FormatVersion != "2.0" { + t.Error("Expected version and format version to be 2.0") + } +} + +func TestSaveAndLoadData(t *testing.T) { + tempDir := t.TempDir() + server := &SyncServer{ + dataFile: filepath.Join(tempDir, "test.json"), + imagesDir: filepath.Join(tempDir, "images"), + } + + testData := &ClimbDataBackup{ + Version: "2.0", + FormatVersion: "2.0", + Gyms: []BackupGym{ + { + ID: "gym1", + Name: "Test Gym", + }, + }, + Problems: []BackupProblem{ + { + ID: "problem1", + GymID: "gym1", + ClimbType: "BOULDER", + Difficulty: DifficultyGrade{ + System: "V", + Grade: "V5", + NumericValue: 5, + }, + IsActive: true, + }, + }, + Sessions: []BackupClimbSession{}, + Attempts: []BackupAttempt{}, + } + + err := server.saveData(testData) + if err != nil { + t.Errorf("saveData() error = %v", err) + } + + loadedData, err := server.loadData() + if err != nil { + t.Errorf("loadData() error = %v", err) + } + + if len(loadedData.Gyms) != 1 || loadedData.Gyms[0].ID != "gym1" { + t.Error("Loaded gym data doesn't match saved data") + } + + if len(loadedData.Problems) != 1 || loadedData.Problems[0].ID != "problem1" { + t.Error("Loaded problem data doesn't match saved data") + } +} + +func TestMinFunction(t *testing.T) { + tests := []struct { + a, b, expected int + }{ + {5, 3, 3}, + {2, 8, 2}, + {4, 4, 4}, + {0, 1, 0}, + {-1, 2, -1}, + } + + for _, tt := range tests { + result := min(tt.a, tt.b) + if result != tt.expected { + t.Errorf("min(%d, %d) = %d, want %d", tt.a, tt.b, result, tt.expected) + } + } +} + +func TestClimbDataBackupValidation(t *testing.T) { + tests := []struct { + name string + backup ClimbDataBackup + isValid bool + }{ + { + name: "Valid backup", + backup: ClimbDataBackup{ + Version: "2.0", + FormatVersion: "2.0", + Gyms: []BackupGym{}, + Problems: []BackupProblem{}, + Sessions: []BackupClimbSession{}, + Attempts: []BackupAttempt{}, + }, + isValid: true, + }, + { + name: "Missing version", + backup: ClimbDataBackup{ + FormatVersion: "2.0", + Gyms: []BackupGym{}, + Problems: []BackupProblem{}, + Sessions: []BackupClimbSession{}, + Attempts: []BackupAttempt{}, + }, + isValid: false, + }, + { + name: "Missing format version", + backup: ClimbDataBackup{ + Version: "2.0", + Gyms: []BackupGym{}, + Problems: []BackupProblem{}, + Sessions: []BackupClimbSession{}, + Attempts: []BackupAttempt{}, + }, + isValid: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + // Test basic validation logic + hasVersion := tt.backup.Version != "" + hasFormatVersion := tt.backup.FormatVersion != "" + isValid := hasVersion && hasFormatVersion + + if isValid != tt.isValid { + t.Errorf("validation = %v, want %v", isValid, tt.isValid) + } + }) + } +} + +func TestBackupDataStructures(t *testing.T) { + t.Run("BackupGym", func(t *testing.T) { + gym := BackupGym{ + ID: "gym1", + Name: "Test Gym", + SupportedClimbTypes: []string{"BOULDER", "ROPE"}, + DifficultySystems: []string{"V", "YDS"}, + CustomDifficultyGrades: []string{}, + CreatedAt: "2024-01-01T10:00:00Z", + UpdatedAt: "2024-01-01T10:00:00Z", + } + + if gym.ID != "gym1" { + t.Errorf("Expected gym ID 'gym1', got %s", gym.ID) + } + + if len(gym.SupportedClimbTypes) != 2 { + t.Errorf("Expected 2 climb types, got %d", len(gym.SupportedClimbTypes)) + } + }) + + t.Run("BackupProblem", func(t *testing.T) { + problem := BackupProblem{ + ID: "problem1", + GymID: "gym1", + ClimbType: "BOULDER", + Difficulty: DifficultyGrade{ + System: "V", + Grade: "V5", + NumericValue: 5, + }, + IsActive: true, + CreatedAt: "2024-01-01T10:00:00Z", + UpdatedAt: "2024-01-01T10:00:00Z", + } + + if problem.ClimbType != "BOULDER" { + t.Errorf("Expected climb type 'BOULDER', got %s", problem.ClimbType) + } + + if problem.Difficulty.Grade != "V5" { + t.Errorf("Expected difficulty 'V5', got %s", problem.Difficulty.Grade) + } + }) +} + +func TestDifficultyGrade(t *testing.T) { + tests := []struct { + name string + grade DifficultyGrade + expectedGrade string + expectedValue int + }{ + { + name: "V-Scale grade", + grade: DifficultyGrade{ + System: "V", + Grade: "V5", + NumericValue: 5, + }, + expectedGrade: "V5", + expectedValue: 5, + }, + { + name: "YDS grade", + grade: DifficultyGrade{ + System: "YDS", + Grade: "5.10a", + NumericValue: 10, + }, + expectedGrade: "5.10a", + expectedValue: 10, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if tt.grade.Grade != tt.expectedGrade { + t.Errorf("Expected grade %s, got %s", tt.expectedGrade, tt.grade.Grade) + } + + if tt.grade.NumericValue != tt.expectedValue { + t.Errorf("Expected numeric value %d, got %d", tt.expectedValue, tt.grade.NumericValue) + } + }) + } +} + +func TestJSONSerialization(t *testing.T) { + backup := ClimbDataBackup{ + Version: "2.0", + FormatVersion: "2.0", + Gyms: []BackupGym{ + { + ID: "gym1", + Name: "Test Gym", + }, + }, + Problems: []BackupProblem{ + { + ID: "problem1", + GymID: "gym1", + ClimbType: "BOULDER", + Difficulty: DifficultyGrade{ + System: "V", + Grade: "V5", + NumericValue: 5, + }, + IsActive: true, + }, + }, + Sessions: []BackupClimbSession{}, + Attempts: []BackupAttempt{}, + } + + // Test JSON marshaling + jsonData, err := json.Marshal(backup) + if err != nil { + t.Errorf("Failed to marshal JSON: %v", err) + } + + // Test JSON unmarshaling + var unmarshaledBackup ClimbDataBackup + err = json.Unmarshal(jsonData, &unmarshaledBackup) + if err != nil { + t.Errorf("Failed to unmarshal JSON: %v", err) + } + + if unmarshaledBackup.Version != backup.Version { + t.Errorf("Version mismatch after JSON round-trip") + } + + if len(unmarshaledBackup.Gyms) != len(backup.Gyms) { + t.Errorf("Gyms count mismatch after JSON round-trip") + } +} + +func TestTimestampHandling(t *testing.T) { + now := time.Now().UTC() + timestamp := now.Format(time.RFC3339) + + // Test that timestamp is in correct format + parsedTime, err := time.Parse(time.RFC3339, timestamp) + if err != nil { + t.Errorf("Failed to parse timestamp: %v", err) + } + + if parsedTime.Year() != now.Year() { + t.Errorf("Year mismatch in timestamp") + } +} + +func TestFilePathHandling(t *testing.T) { + tempDir := t.TempDir() + + tests := []struct { + name string + filename string + isValid bool + }{ + {"Valid filename", "test.json", true}, + {"Valid path", filepath.Join(tempDir, "data.json"), true}, + {"Empty filename", "", false}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + isEmpty := tt.filename == "" + isValid := !isEmpty + + if isValid != tt.isValid { + t.Errorf("File path validation = %v, want %v", isValid, tt.isValid) + } + }) + } +}