Compare commits

..

4 Commits

Author SHA1 Message Date
b8f874a433 Cleanup 2025-10-05 12:42:02 -06:00
4bbd422c09 Added a proper set of Unit Tests for each sub-project
All checks were successful
OpenClimb Docker Deploy / build-and-push (push) Successful in 2m28s
2025-10-03 20:55:04 -06:00
4e42985135 1.2.2 - "Bug fixes and improvements" 2025-10-01 21:34:22 -06:00
ba1a7117d9 Create PRIVACY.md 2025-09-29 14:11:01 -06:00
61 changed files with 3476 additions and 1423 deletions

View File

@@ -5,11 +5,6 @@ on:
paths: paths:
- "sync/**" - "sync/**"
- ".github/workflows/deploy.yml" - ".github/workflows/deploy.yml"
pull_request:
branches: [main]
paths:
- "sync/**"
- ".github/workflows/deploy.yml"
jobs: jobs:
build-and-push: build-and-push:

21
PRIVACY.md Normal file
View File

@@ -0,0 +1,21 @@
# Privacy Policy
**Last updated: September 29, 2025**
This Privacy Policy describes our policies and procedures regarding the collection, use, and disclosure of your information when you use my software.
## No Data Collection
I do not collect any personal information, analytics, or data of any kind. This software is designed to be self-hosted or run entirely offline.
All data generated by or used with this software remains on your local machine or self-hosted environment under your control. I have no access to it.
## No Tracking or Analytics
This software does not use cookies, tracking pixels, or any other analytics or tracking mechanisms. Your usage of the software is completely private.
## Contact Us
If you have any questions about this Privacy Policy, you can contact me:
* **By email:** me@atri.dad

View File

@@ -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. 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 ## Download
For Android do one of the following: 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)! 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 ## Requirements
- Android 12+ or iOS 17+ - Android 12+ or iOS 17+

View File

@@ -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)
}

View File

@@ -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)
}
}

View File

@@ -3,7 +3,7 @@ package com.atridad.openclimb.data.format
import com.atridad.openclimb.data.model.* import com.atridad.openclimb.data.model.*
import kotlinx.serialization.Serializable import kotlinx.serialization.Serializable
/** Root structure for OpenClimb backup data */ // Root structure for OpenClimb backup data
@Serializable @Serializable
data class ClimbDataBackup( data class ClimbDataBackup(
val exportedAt: String, val exportedAt: String,
@@ -15,7 +15,7 @@ data class ClimbDataBackup(
val attempts: List<BackupAttempt> val attempts: List<BackupAttempt>
) )
/** Platform-neutral gym representation for backup/restore */ // Platform-neutral gym representation for backup/restore
@Serializable @Serializable
data class BackupGym( data class BackupGym(
val id: String, val id: String,
@@ -26,8 +26,8 @@ data class BackupGym(
@kotlinx.serialization.SerialName("customDifficultyGrades") @kotlinx.serialization.SerialName("customDifficultyGrades")
val customDifficultyGrades: List<String> = emptyList(), val customDifficultyGrades: List<String> = emptyList(),
val notes: String? = null, val notes: String? = null,
val createdAt: String, // ISO 8601 format val createdAt: String,
val updatedAt: String // ISO 8601 format val updatedAt: String
) { ) {
companion object { companion object {
/** Create BackupGym from native Android Gym model */ /** 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 @Serializable
data class BackupProblem( data class BackupProblem(
val id: String, val id: String,
@@ -75,10 +75,10 @@ data class BackupProblem(
val location: String? = null, val location: String? = null,
val imagePaths: List<String>? = null, val imagePaths: List<String>? = null,
val isActive: Boolean = true, val isActive: Boolean = true,
val dateSet: String? = null, // ISO 8601 format val dateSet: String? = null,
val notes: String? = null, val notes: String? = null,
val createdAt: String, // ISO 8601 format val createdAt: String,
val updatedAt: String // ISO 8601 format val updatedAt: String
) { ) {
companion object { companion object {
/** Create BackupProblem from native Android Problem model */ /** Create BackupProblem from native Android Problem model */
@@ -94,11 +94,7 @@ data class BackupProblem(
location = problem.location, location = problem.location,
imagePaths = imagePaths =
if (problem.imagePaths.isEmpty()) null if (problem.imagePaths.isEmpty()) null
else else problem.imagePaths.map { path -> path.substringAfterLast('/') },
problem.imagePaths.map { path ->
// Store just the filename to match iOS format
path.substringAfterLast('/')
},
isActive = problem.isActive, isActive = problem.isActive,
dateSet = problem.dateSet, dateSet = problem.dateSet,
notes = problem.notes, 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 @Serializable
data class BackupClimbSession( data class BackupClimbSession(
val id: String, val id: String,
val gymId: String, val gymId: String,
val date: String, // ISO 8601 format val date: String,
val startTime: String? = null, // ISO 8601 format val startTime: String? = null,
val endTime: String? = null, // ISO 8601 format val endTime: String? = null,
val duration: Long? = null, // Duration in seconds val duration: Long? = null,
val status: SessionStatus, val status: SessionStatus,
val notes: String? = null, val notes: String? = null,
val createdAt: String, // ISO 8601 format val createdAt: String,
val updatedAt: String // ISO 8601 format val updatedAt: String
) { ) {
companion object { companion object {
/** Create BackupClimbSession from native Android ClimbSession model */ /** 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 @Serializable
data class BackupAttempt( data class BackupAttempt(
val id: String, val id: String,
@@ -192,10 +188,11 @@ data class BackupAttempt(
val result: AttemptResult, val result: AttemptResult,
val highestHold: String? = null, val highestHold: String? = null,
val notes: String? = null, val notes: String? = null,
val duration: Long? = null, // Duration in seconds val duration: Long? = null,
val restTime: Long? = null, // Rest time in seconds val restTime: Long? = null,
val timestamp: String, // ISO 8601 format val timestamp: String,
val createdAt: String // ISO 8601 format val createdAt: String,
val updatedAt: String
) { ) {
companion object { companion object {
/** Create BackupAttempt from native Android Attempt model */ /** Create BackupAttempt from native Android Attempt model */

View File

@@ -57,7 +57,7 @@ class ImageMigrationService(private val context: Context, private val repository
migrationResults.putAll(problemMigrations) migrationResults.putAll(problemMigrations)
migratedCount += problemMigrations.size migratedCount += problemMigrations.size
// Update problem with new image paths // Update image paths
val newImagePaths = val newImagePaths =
problem.imagePaths.map { oldPath -> problem.imagePaths.map { oldPath ->
problemMigrations[oldPath] ?: oldPath problemMigrations[oldPath] ?: oldPath
@@ -120,7 +120,7 @@ class ImageMigrationService(private val context: Context, private val repository
continue continue
} }
// Check if filename follows our convention // Check if filename follows convention
if (ImageNamingUtils.isValidImageFilename(filename)) { if (ImageNamingUtils.isValidImageFilename(filename)) {
validImages.add(imagePath) validImages.add(imagePath)
} else { } else {

View File

@@ -39,11 +39,11 @@ data class Attempt(
val sessionId: String, val sessionId: String,
val problemId: String, val problemId: String,
val result: AttemptResult, val result: AttemptResult,
val highestHold: String? = null, // Description of the highest hold reached val highestHold: String? = null,
val notes: String? = null, val notes: String? = null,
val duration: Long? = null, // Attempt duration in seconds val duration: Long? = null,
val restTime: Long? = null, // Rest time before this attempt in seconds val restTime: Long? = null,
val timestamp: String, // When this attempt was made val timestamp: String,
val createdAt: String val createdAt: String
) { ) {
companion object { companion object {

View File

@@ -5,13 +5,11 @@ import kotlinx.serialization.Serializable
@Serializable @Serializable
enum class DifficultySystem { enum class DifficultySystem {
// Bouldering // Bouldering
V_SCALE, // V-Scale (VB - V17) V_SCALE,
FONT, // Fontainebleau (3 - 8C+) FONT,
// Rope // Rope
YDS, // Yosemite Decimal System (5.0 - 5.15d) YDS,
// Custom difficulty systems
CUSTOM; CUSTOM;
/** Get the display name for the UI */ /** Get the display name for the UI */
@@ -28,7 +26,7 @@ enum class DifficultySystem {
when (this) { when (this) {
V_SCALE, FONT -> true V_SCALE, FONT -> true
YDS -> false YDS -> false
CUSTOM -> true // Custom is available for all CUSTOM -> true
} }
/** Check if this system is for rope climbing */ /** 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 if (grade == "VB") 0 else grade.removePrefix("V").toIntOrNull() ?: 0
} }
DifficultySystem.YDS -> { DifficultySystem.YDS -> {
// Simplified numeric mapping for YDS grades
when { when {
grade.startsWith("5.10") -> grade.startsWith("5.10") ->
10 + (grade.getOrNull(4)?.toString()?.hashCode()?.rem(4) ?: 0) 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 -> { DifficultySystem.FONT -> {
// Simplified Font grade mapping
when { when {
grade.startsWith("6A") -> 6 grade.startsWith("6A") -> 6
grade.startsWith("6B") -> 7 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 { private fun compareVScaleGrades(grade1: String, grade2: String): Int {
// Handle VB (easiest) specially
if (grade1 == "VB" && grade2 != "VB") return -1 if (grade1 == "VB" && grade2 != "VB") return -1
if (grade2 == "VB" && grade1 != "VB") return 1 if (grade2 == "VB" && grade1 != "VB") return 1
if (grade1 == "VB" && grade2 == "VB") return 0 if (grade1 == "VB" && grade2 == "VB") return 0
// Extract numeric values for V grades
val num1 = grade1.removePrefix("V").toIntOrNull() ?: 0 val num1 = grade1.removePrefix("V").toIntOrNull() ?: 0
val num2 = grade2.removePrefix("V").toIntOrNull() ?: 0 val num2 = grade2.removePrefix("V").toIntOrNull() ?: 0
return num1.compareTo(num2) return num1.compareTo(num2)
} }
private fun compareFontGrades(grade1: String, grade2: String): Int { private fun compareFontGrades(grade1: String, grade2: String): Int {
// Simple string comparison for Font grades
return grade1.compareTo(grade2) return grade1.compareTo(grade2)
} }
private fun compareYDSGrades(grade1: String, grade2: String): Int { private fun compareYDSGrades(grade1: String, grade2: String): Int {
// Simple string comparison for YDS grades
return grade1.compareTo(grade2) return grade1.compareTo(grade2)
} }
} }

View File

@@ -23,7 +23,6 @@ class ClimbRepository(database: OpenClimbDatabase, private val context: Context)
private val attemptDao = database.attemptDao() private val attemptDao = database.attemptDao()
private val dataStateManager = DataStateManager(context) private val dataStateManager = DataStateManager(context)
// Callback interface for auto-sync functionality
private var autoSyncCallback: (() -> Unit)? = null private var autoSyncCallback: (() -> Unit)? = null
private val json = Json { 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) { suspend fun exportAllDataToZipUri(context: Context, uri: android.net.Uri) {
try { try {
// Collect all data
val allGyms = gymDao.getAllGyms().first() val allGyms = gymDao.getAllGyms().first()
val allProblems = problemDao.getAllProblems().first() val allProblems = problemDao.getAllProblems().first()
val allSessions = sessionDao.getAllSessions().first() val allSessions = sessionDao.getAllSessions().first()
val allAttempts = attemptDao.getAllAttempts().first() val allAttempts = attemptDao.getAllAttempts().first()
// Validate data integrity before export
validateDataIntegrity(allGyms, allProblems, allSessions, allAttempts) validateDataIntegrity(allGyms, allProblems, allSessions, allAttempts)
// Create backup data using platform-neutral format
val backupData = val backupData =
ClimbDataBackup( ClimbDataBackup(
exportedAt = DateFormatUtils.nowISO8601(), exportedAt = DateFormatUtils.nowISO8601(),
@@ -146,7 +142,6 @@ class ClimbRepository(database: OpenClimbDatabase, private val context: Context)
attempts = allAttempts.map { BackupAttempt.fromAttempt(it) } attempts = allAttempts.map { BackupAttempt.fromAttempt(it) }
) )
// Collect all referenced image paths and validate they exist
val referencedImagePaths = allProblems.flatMap { it.imagePaths }.toSet() val referencedImagePaths = allProblems.flatMap { it.imagePaths }.toSet()
val validImagePaths = val validImagePaths =
referencedImagePaths referencedImagePaths
@@ -177,20 +172,16 @@ class ClimbRepository(database: OpenClimbDatabase, private val context: Context)
suspend fun importDataFromZip(file: File) { suspend fun importDataFromZip(file: File) {
try { try {
// Validate the ZIP file
if (!file.exists() || file.length() == 0L) { if (!file.exists() || file.length() == 0L) {
throw Exception("Invalid ZIP file: file is empty or doesn't exist") throw Exception("Invalid ZIP file: file is empty or doesn't exist")
} }
// Extract and validate the ZIP contents
val importResult = ZipExportImportUtils.extractImportZip(context, file) val importResult = ZipExportImportUtils.extractImportZip(context, file)
// Validate JSON content
if (importResult.jsonContent.isBlank()) { if (importResult.jsonContent.isBlank()) {
throw Exception("Invalid ZIP file: no data.json found or empty content") throw Exception("Invalid ZIP file: no data.json found or empty content")
} }
// Parse and validate the data structure
val importData = val importData =
try { try {
json.decodeFromString<ClimbDataBackup>(importResult.jsonContent) json.decodeFromString<ClimbDataBackup>(importResult.jsonContent)
@@ -198,17 +189,13 @@ class ClimbRepository(database: OpenClimbDatabase, private val context: Context)
throw Exception("Invalid data format: ${e.message}") throw Exception("Invalid data format: ${e.message}")
} }
// Validate data integrity
validateImportData(importData) validateImportData(importData)
// Clear existing data to avoid conflicts
attemptDao.deleteAllAttempts() attemptDao.deleteAllAttempts()
sessionDao.deleteAllSessions() sessionDao.deleteAllSessions()
problemDao.deleteAllProblems() problemDao.deleteAllProblems()
gymDao.deleteAllGyms() gymDao.deleteAllGyms()
// Import gyms first (problems depend on gyms) - use DAO directly to avoid multiple data
// state updates
importData.gyms.forEach { backupGym -> importData.gyms.forEach { backupGym ->
try { try {
gymDao.insertGym(backupGym.toGym()) gymDao.insertGym(backupGym.toGym())
@@ -217,14 +204,12 @@ class ClimbRepository(database: OpenClimbDatabase, private val context: Context)
} }
} }
// Import problems with updated image paths
val updatedBackupProblems = val updatedBackupProblems =
ZipExportImportUtils.updateProblemImagePaths( ZipExportImportUtils.updateProblemImagePaths(
importData.problems, importData.problems,
importResult.importedImagePaths importResult.importedImagePaths
) )
// Import problems (depends on gyms) - use DAO directly
updatedBackupProblems.forEach { backupProblem -> updatedBackupProblems.forEach { backupProblem ->
try { try {
problemDao.insertProblem(backupProblem.toProblem()) 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 -> importData.sessions.forEach { backupSession ->
try { try {
sessionDao.insertSession(backupSession.toClimbSession()) 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 -> importData.attempts.forEach { backupAttempt ->
try { try {
attemptDao.insertAttempt(backupAttempt.toAttempt()) 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() dataStateManager.updateDataState()
} catch (e: Exception) { } catch (e: Exception) {
throw Exception("Import failed: ${e.message}") throw Exception("Import failed: ${e.message}")
@@ -282,7 +264,6 @@ class ClimbRepository(database: OpenClimbDatabase, private val context: Context)
sessions: List<ClimbSession>, sessions: List<ClimbSession>,
attempts: List<Attempt> attempts: List<Attempt>
) { ) {
// Validate that all problems reference valid gyms
val gymIds = gyms.map { it.id }.toSet() val gymIds = gyms.map { it.id }.toSet()
val invalidProblems = problems.filter { it.gymId !in gymIds } val invalidProblems = problems.filter { it.gymId !in gymIds }
if (invalidProblems.isNotEmpty()) { 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 } val invalidSessions = sessions.filter { it.gymId !in gymIds }
if (invalidSessions.isNotEmpty()) { if (invalidSessions.isNotEmpty()) {
throw Exception( 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 problemIds = problems.map { it.id }.toSet()
val sessionIds = sessions.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") throw Exception("Import data is invalid: no version information")
} }
// Check for reasonable data sizes to prevent malicious imports
if (importData.gyms.size > 1000 || if (importData.gyms.size > 1000 ||
importData.problems.size > 10000 || importData.problems.size > 10000 ||
importData.sessions.size > 10000 || importData.sessions.size > 10000 ||
@@ -333,27 +311,22 @@ class ClimbRepository(database: OpenClimbDatabase, private val context: Context)
suspend fun resetAllData() { suspend fun resetAllData() {
try { try {
// Temporarily disable auto-sync during reset
val originalCallback = autoSyncCallback val originalCallback = autoSyncCallback
autoSyncCallback = null autoSyncCallback = null
// Clear all data from database
attemptDao.deleteAllAttempts() attemptDao.deleteAllAttempts()
sessionDao.deleteAllSessions() sessionDao.deleteAllSessions()
problemDao.deleteAllProblems() problemDao.deleteAllProblems()
gymDao.deleteAllGyms() gymDao.deleteAllGyms()
// Clear all images from storage
clearAllImages() clearAllImages()
// Restore auto-sync callback
autoSyncCallback = originalCallback autoSyncCallback = originalCallback
} catch (e: Exception) { } catch (e: Exception) {
throw Exception("Reset failed: ${e.message}") throw Exception("Reset failed: ${e.message}")
} }
} }
// Import methods that bypass auto-sync to avoid triggering sync during data restoration
suspend fun insertGymWithoutSync(gym: Gym) { suspend fun insertGymWithoutSync(gym: Gym) {
gymDao.insertGym(gym) gymDao.insertGym(gym)
dataStateManager.updateDataState() dataStateManager.updateDataState()
@@ -376,7 +349,6 @@ class ClimbRepository(database: OpenClimbDatabase, private val context: Context)
private fun clearAllImages() { private fun clearAllImages() {
try { try {
// Get the images directory
val imagesDir = File(context.filesDir, "images") val imagesDir = File(context.filesDir, "images")
if (imagesDir.exists() && imagesDir.isDirectory) { if (imagesDir.exists() && imagesDir.isDirectory) {
val deletedCount = imagesDir.listFiles()?.size ?: 0 val deletedCount = imagesDir.listFiles()?.size ?: 0

View File

@@ -22,7 +22,6 @@ class DataStateManager(context: Context) {
context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE) context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
init { init {
// Initialize with current timestamp if this is the first time
if (!isInitialized()) { if (!isInitialized()) {
updateDataState() updateDataState()
markAsInitialized() markAsInitialized()

View File

@@ -3,6 +3,7 @@ package com.atridad.openclimb.data.sync
import android.content.Context import android.content.Context
import android.content.SharedPreferences import android.content.SharedPreferences
import android.util.Log import android.util.Log
import androidx.core.content.edit
import com.atridad.openclimb.data.format.BackupAttempt import com.atridad.openclimb.data.format.BackupAttempt
import com.atridad.openclimb.data.format.BackupClimbSession import com.atridad.openclimb.data.format.BackupClimbSession
import com.atridad.openclimb.data.format.BackupGym import com.atridad.openclimb.data.format.BackupGym
@@ -31,7 +32,6 @@ import okhttp3.MediaType.Companion.toMediaType
import okhttp3.OkHttpClient import okhttp3.OkHttpClient
import okhttp3.Request import okhttp3.Request
import okhttp3.RequestBody.Companion.toRequestBody import okhttp3.RequestBody.Companion.toRequestBody
import androidx.core.content.edit
class SyncService(private val context: Context, private val repository: ClimbRepository) { 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 coerceInputValues = true
} }
// State flows // State
private val _isSyncing = MutableStateFlow(false) private val _isSyncing = MutableStateFlow(false)
val isSyncing: StateFlow<Boolean> = _isSyncing.asStateFlow() val isSyncing: StateFlow<Boolean> = _isSyncing.asStateFlow()
@@ -109,15 +109,11 @@ class SyncService(private val context: Context, private val repository: ClimbRep
} }
init { init {
// Initialize state from preferences
_isConnected.value = sharedPreferences.getBoolean(Keys.IS_CONNECTED, false) _isConnected.value = sharedPreferences.getBoolean(Keys.IS_CONNECTED, false)
_lastSyncTime.value = sharedPreferences.getString(Keys.LAST_SYNC_TIME, null) _lastSyncTime.value = sharedPreferences.getString(Keys.LAST_SYNC_TIME, null)
// Register auto-sync callback with repository
repository.setAutoSyncCallback { repository.setAutoSyncCallback {
kotlinx.coroutines.CoroutineScope(Dispatchers.IO).launch { kotlinx.coroutines.CoroutineScope(Dispatchers.IO).launch { triggerAutoSync() }
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}" "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 -> backup.problems.forEach { problem ->
val imageCount = problem.imagePaths?.size ?: 0 val imageCount = problem.imagePaths?.size ?: 0
if (imageCount > 0) { if (imageCount > 0) {
@@ -236,8 +231,6 @@ class SyncService(private val context: Context, private val repository: ClimbRep
throw SyncException.NotConfigured 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 justFilename = filename.substringAfterLast('/')
val requestBody = imageData.toRequestBody("image/*".toMediaType()) 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() val response = httpClient.newCall(request).execute()
when (response.code) { when (response.code) {
200 -> Unit // Success 200 -> Unit
401 -> throw SyncException.Unauthorized 401 -> throw SyncException.Unauthorized
else -> { else -> {
val errorBody = response.body?.string() ?: "No error details" 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 throw SyncException.NotConnected
} }
// Prevent concurrent sync operations
syncMutex.withLock { syncMutex.withLock {
_isSyncing.value = true _isSyncing.value = true
_syncError.value = null _syncError.value = null
try { try {
// Fix existing image paths first
Log.d(TAG, "Fixing existing image paths before sync") Log.d(TAG, "Fixing existing image paths before sync")
val pathFixSuccess = fixImagePaths() val pathFixSuccess = fixImagePaths()
if (!pathFixSuccess) { if (!pathFixSuccess) {
Log.w(TAG, "Image path fix failed, but continuing with sync") 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") Log.d(TAG, "Performing image migration before sync")
val migrationSuccess = migrateImagesForSync() val migrationSuccess = migrateImagesForSync()
if (!migrationSuccess) { if (!migrationSuccess) {
Log.w(TAG, "Image migration failed, but continuing with sync") Log.w(TAG, "Image migration failed, but continuing with sync")
} }
// Get local backup data
val localBackup = createBackupFromRepository() val localBackup = createBackupFromRepository()
// Download server data
val serverBackup = downloadData() val serverBackup = downloadData()
// Check if we have any local data
val hasLocalData = val hasLocalData =
localBackup.gyms.isNotEmpty() || localBackup.gyms.isNotEmpty() ||
localBackup.problems.isNotEmpty() || localBackup.problems.isNotEmpty() ||
@@ -366,21 +353,18 @@ class SyncService(private val context: Context, private val repository: ClimbRep
when { when {
!hasLocalData && hasServerData -> { !hasLocalData && hasServerData -> {
// Case 1: No local data - do full restore from server
Log.d(TAG, "No local data found, performing full restore from server") Log.d(TAG, "No local data found, performing full restore from server")
val imagePathMapping = syncImagesFromServer(serverBackup) val imagePathMapping = syncImagesFromServer(serverBackup)
importBackupToRepository(serverBackup, imagePathMapping) importBackupToRepository(serverBackup, imagePathMapping)
Log.d(TAG, "Full restore completed") Log.d(TAG, "Full restore completed")
} }
hasLocalData && !hasServerData -> { hasLocalData && !hasServerData -> {
// Case 2: No server data - upload local data to server
Log.d(TAG, "No server data found, uploading local data to server") Log.d(TAG, "No server data found, uploading local data to server")
uploadData(localBackup) uploadData(localBackup)
syncImagesForBackup(localBackup) syncImagesForBackup(localBackup)
Log.d(TAG, "Initial upload completed") Log.d(TAG, "Initial upload completed")
} }
hasLocalData && hasServerData -> { hasLocalData && hasServerData -> {
// Case 3: Both have data - compare timestamps (last writer wins)
val localTimestamp = parseISO8601ToMillis(localBackup.exportedAt) val localTimestamp = parseISO8601ToMillis(localBackup.exportedAt)
val serverTimestamp = parseISO8601ToMillis(serverBackup.exportedAt) val serverTimestamp = parseISO8601ToMillis(serverBackup.exportedAt)
@@ -390,19 +374,16 @@ class SyncService(private val context: Context, private val repository: ClimbRep
) )
if (localTimestamp > serverTimestamp) { if (localTimestamp > serverTimestamp) {
// Local is newer - replace server with local data
Log.d(TAG, "Local data is newer, replacing server content") Log.d(TAG, "Local data is newer, replacing server content")
uploadData(localBackup) uploadData(localBackup)
syncImagesForBackup(localBackup) syncImagesForBackup(localBackup)
Log.d(TAG, "Server replaced with local data") Log.d(TAG, "Server replaced with local data")
} else if (serverTimestamp > localTimestamp) { } else if (serverTimestamp > localTimestamp) {
// Server is newer - replace local with server data
Log.d(TAG, "Server data is newer, replacing local content") Log.d(TAG, "Server data is newer, replacing local content")
val imagePathMapping = syncImagesFromServer(serverBackup) val imagePathMapping = syncImagesFromServer(serverBackup)
importBackupToRepository(serverBackup, imagePathMapping) importBackupToRepository(serverBackup, imagePathMapping)
Log.d(TAG, "Local data replaced with server data") Log.d(TAG, "Local data replaced with server data")
} else { } else {
// Timestamps are equal - no sync needed
Log.d(TAG, "Data is in sync (timestamps equal), no action 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() val now = DateFormatUtils.nowISO8601()
_lastSyncTime.value = now _lastSyncTime.value = now
sharedPreferences.edit().putString(Keys.LAST_SYNC_TIME, now).apply() 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") Log.d(TAG, "Attempting to download image: $imagePath")
val imageData = downloadImage(imagePath) val imageData = downloadImage(imagePath)
// Extract filename and ensure it follows our naming convention
val serverFilename = imagePath.substringAfterLast('/') val serverFilename = imagePath.substringAfterLast('/')
val consistentFilename = val consistentFilename =
if (ImageNamingUtils.isValidImageFilename(serverFilename)) { if (ImageNamingUtils.isValidImageFilename(serverFilename)) {
serverFilename serverFilename
} else { } else {
// Generate consistent filename using problem ID and index
ImageNamingUtils.generateImageFilename(problem.id, index) ImageNamingUtils.generateImageFilename(problem.id, index)
} }
@@ -465,7 +443,6 @@ class SyncService(private val context: Context, private val repository: ClimbRep
) )
if (localImagePath != null) { if (localImagePath != null) {
// Map original server filename to the full local relative path
imagePathMapping[serverFilename] = localImagePath imagePathMapping[serverFilename] = localImagePath
downloadedImages++ downloadedImages++
Log.d( Log.d(
@@ -516,12 +493,10 @@ class SyncService(private val context: Context, private val repository: ClimbRep
val imageData = imageFile.readBytes() val imageData = imageFile.readBytes()
val filename = imagePath.substringAfterLast('/') val filename = imagePath.substringAfterLast('/')
// Ensure filename follows our naming convention
val consistentFilename = val consistentFilename =
if (ImageNamingUtils.isValidImageFilename(filename)) { if (ImageNamingUtils.isValidImageFilename(filename)) {
filename filename
} else { } else {
// Generate consistent filename and rename the local file
val newFilename = val newFilename =
ImageNamingUtils.generateImageFilename( ImageNamingUtils.generateImageFilename(
problem.id, problem.id,
@@ -533,7 +508,6 @@ class SyncService(private val context: Context, private val repository: ClimbRep
TAG, TAG,
"Renamed local image file: $filename -> $newFilename" "Renamed local image file: $filename -> $newFilename"
) )
// Update the problem's image path in memory for next sync
newFilename newFilename
} else { } else {
Log.w( Log.w(
@@ -589,10 +563,8 @@ class SyncService(private val context: Context, private val repository: ClimbRep
backup: ClimbDataBackup, backup: ClimbDataBackup,
imagePathMapping: Map<String, String> = emptyMap() imagePathMapping: Map<String, String> = emptyMap()
) { ) {
// Clear existing data to avoid conflicts
repository.resetAllData() repository.resetAllData()
// Import gyms first (problems depend on gyms)
backup.gyms.forEach { backupGym -> backup.gyms.forEach { backupGym ->
try { try {
val gym = backupGym.toGym() val gym = backupGym.toGym()
@@ -600,21 +572,18 @@ class SyncService(private val context: Context, private val repository: ClimbRep
repository.insertGymWithoutSync(gym) repository.insertGymWithoutSync(gym)
} catch (e: Exception) { } catch (e: Exception) {
Log.e(TAG, "Failed to import gym '${backupGym.name}': ${e.message}") 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 -> backup.problems.forEach { backupProblem ->
try { try {
val updatedProblem = val updatedProblem =
if (imagePathMapping.isNotEmpty()) { if (imagePathMapping.isNotEmpty()) {
val newImagePaths = val newImagePaths =
backupProblem.imagePaths?.map { oldPath -> backupProblem.imagePaths?.map { oldPath ->
// Extract filename and check mapping
val filename = oldPath.substringAfterLast('/') val filename = oldPath.substringAfterLast('/')
// Use mapped full path or fallback to consistent naming
// with full path
imagePathMapping[filename] imagePathMapping[filename]
?: if (ImageNamingUtils.isValidImageFilename( ?: if (ImageNamingUtils.isValidImageFilename(
filename filename
@@ -622,8 +591,6 @@ class SyncService(private val context: Context, private val repository: ClimbRep
) { ) {
"problem_images/$filename" "problem_images/$filename"
} else { } else {
// Generate consistent filename as fallback with
// full path
val index = val index =
backupProblem.imagePaths.indexOf( backupProblem.imagePaths.indexOf(
oldPath oldPath
@@ -647,7 +614,6 @@ class SyncService(private val context: Context, private val repository: ClimbRep
} }
} }
// Import sessions
backup.sessions.forEach { backupSession -> backup.sessions.forEach { backupSession ->
try { try {
repository.insertSessionWithoutSync(backupSession.toClimbSession()) 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 -> backup.attempts.forEach { backupAttempt ->
try { try {
repository.insertAttemptWithoutSync(backupAttempt.toAttempt()) 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) dataStateManager.setLastModified(backup.exportedAt)
Log.d(TAG, "Data state synchronized to imported timestamp: ${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 = val fixedPaths =
problem.imagePaths.map { path -> problem.imagePaths.map { path ->
if (!path.startsWith("problem_images/") && !path.contains("/")) { if (!path.startsWith("problem_images/") && !path.contains("/")) {
// Just a filename, add the directory prefix
val fixedPath = "problem_images/$path" val fixedPath = "problem_images/$path"
Log.d(TAG, "Fixed path: $path -> $fixedPath") Log.d(TAG, "Fixed path: $path -> $fixedPath")
fixedCount++ fixedCount++
@@ -798,7 +761,6 @@ class SyncService(private val context: Context, private val repository: ClimbRep
return return
} }
// Check if sync is already running to prevent duplicate attempts
if (_isSyncing.value) { if (_isSyncing.value) {
Log.d(TAG, "Sync already in progress, skipping auto-sync") Log.d(TAG, "Sync already in progress, skipping auto-sync")
return return

View File

@@ -4,39 +4,27 @@ import kotlinx.serialization.Serializable
@Serializable @Serializable
sealed class Screen { sealed class Screen {
@Serializable @Serializable data object Sessions : Screen()
data object Sessions : Screen()
@Serializable @Serializable data object Problems : Screen()
data object Problems : Screen()
@Serializable @Serializable data object Analytics : Screen()
data object Analytics : Screen()
@Serializable @Serializable data object Gyms : Screen()
data object Gyms : Screen()
@Serializable @Serializable data object Settings : Screen()
data object Settings : Screen()
// Detail screens @Serializable data class SessionDetail(val sessionId: String) : Screen()
@Serializable
data class SessionDetail(val sessionId: String) : Screen()
@Serializable @Serializable data class ProblemDetail(val problemId: String) : Screen()
data class ProblemDetail(val problemId: String) : Screen()
@Serializable @Serializable data class GymDetail(val gymId: String) : Screen()
data class GymDetail(val gymId: String) : Screen()
@Serializable @Serializable data class AddEditGym(val gymId: String? = null) : Screen()
data class AddEditGym(val gymId: String? = null) : Screen()
@Serializable @Serializable
data class AddEditProblem(val problemId: String? = null, val gymId: String? = null) : Screen() data class AddEditProblem(val problemId: String? = null, val gymId: String? = null) : Screen()
@Serializable @Serializable
data class AddEditSession(val sessionId: String? = null, val gymId: String? = null) : Screen() data class AddEditSession(val sessionId: String? = null, val gymId: String? = null) : Screen()
} }

View File

@@ -47,11 +47,9 @@ fun OpenClimbApp(
val viewModel: ClimbViewModel = val viewModel: ClimbViewModel =
viewModel(factory = ClimbViewModelFactory(repository, syncService)) viewModel(factory = ClimbViewModelFactory(repository, syncService))
// Notification permission state
var showNotificationPermissionDialog by remember { mutableStateOf(false) } var showNotificationPermissionDialog by remember { mutableStateOf(false) }
var hasCheckedNotificationPermission by remember { mutableStateOf(false) } var hasCheckedNotificationPermission by remember { mutableStateOf(false) }
// Permission launcher
val permissionLauncher = val permissionLauncher =
rememberLauncherForActivityResult( rememberLauncherForActivityResult(
contract = ActivityResultContracts.RequestPermission() contract = ActivityResultContracts.RequestPermission()
@@ -75,13 +73,11 @@ fun OpenClimbApp(
LaunchedEffect(Unit) { viewModel.ensureSessionTrackingServiceRunning(context) } LaunchedEffect(Unit) { viewModel.ensureSessionTrackingServiceRunning(context) }
// Trigger auto-sync on app launch
LaunchedEffect(Unit) { syncService.triggerAutoSync() } LaunchedEffect(Unit) { syncService.triggerAutoSync() }
val activeSession by viewModel.activeSession.collectAsState() val activeSession by viewModel.activeSession.collectAsState()
val gyms by viewModel.gyms.collectAsState() val gyms by viewModel.gyms.collectAsState()
// Update last used gym when gyms change
LaunchedEffect(gyms) { LaunchedEffect(gyms) {
if (gyms.isNotEmpty() && lastUsedGym == null) { if (gyms.isNotEmpty() && lastUsedGym == null) {
lastUsedGym = viewModel.getLastUsedGym() lastUsedGym = viewModel.getLastUsedGym()
@@ -116,7 +112,6 @@ fun OpenClimbApp(
} }
} }
// Process shortcut actions after data is loaded
LaunchedEffect(shortcutAction, activeSession, gyms, lastUsedGym) { LaunchedEffect(shortcutAction, activeSession, gyms, lastUsedGym) {
if (shortcutAction == AppShortcutManager.ACTION_START_SESSION && gyms.isNotEmpty()) { if (shortcutAction == AppShortcutManager.ACTION_START_SESSION && gyms.isNotEmpty()) {
android.util.Log.d( android.util.Log.d(
@@ -140,7 +135,6 @@ fun OpenClimbApp(
) )
viewModel.startSession(context, gyms.first().id) viewModel.startSession(context, gyms.first().id)
} else { } else {
// Try to get the last used gym from the intent or fallback to state
val targetGym = val targetGym =
lastUsedGymId?.let { gymId -> gyms.find { it.id == gymId } } lastUsedGymId?.let { gymId -> gyms.find { it.id == gymId } }
?: lastUsedGym ?: lastUsedGym
@@ -167,7 +161,6 @@ fun OpenClimbApp(
) )
} }
// Clear the shortcut action after processing to prevent repeated execution
onShortcutActionProcessed() onShortcutActionProcessed()
} }
} }
@@ -215,8 +208,6 @@ fun OpenClimbApp(
if (gyms.size == 1) { if (gyms.size == 1) {
viewModel.startSession(context, gyms.first().id) viewModel.startSession(context, gyms.first().id)
} else { } else {
// Always show gym selection for FAB when
// multiple gyms
navController.navigate(Screen.AddEditSession()) navController.navigate(Screen.AddEditSession())
} }
} }
@@ -362,7 +353,6 @@ fun OpenClimbApp(
} }
} }
// Notification permission dialog
if (showNotificationPermissionDialog) { if (showNotificationPermissionDialog) {
NotificationPermissionDialog( NotificationPermissionDialog(
onDismiss = { showNotificationPermissionDialog = false }, onDismiss = { showNotificationPermissionDialog = false },
@@ -399,10 +389,7 @@ fun OpenClimbBottomNavigation(navController: NavHostController) {
selected = isSelected, selected = isSelected,
onClick = { onClick = {
navController.navigate(item.screen) { navController.navigate(item.screen) {
// Clear the entire back stack and go to the selected tab's root screen
popUpTo(0) { inclusive = true } popUpTo(0) { inclusive = true }
// Avoid multiple copies of the same destination when
// reselecting the same item
launchSingleTop = true launchSingleTop = true
// Don't restore state - always start fresh when switching tabs // Don't restore state - always start fresh when switching tabs
restoreState = false restoreState = false

View File

@@ -54,15 +54,15 @@ fun BarChart(
val chartWidth = size.width - padding * 2 val chartWidth = size.width - padding * 2
val chartHeight = size.height - 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 } val sortedData = data.sortedBy { it.gradeNumeric }
// Calculate max value for scaling // Calculate max value
val maxValue = sortedData.maxOfOrNull { it.value } ?: 1 val maxValue = sortedData.maxOfOrNull { it.value } ?: 1
// Calculate bar dimensions // Bar dimensions
val barCount = sortedData.size 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 barSpacing = if (barCount > 1) totalSpacing / (barCount + 1) else totalSpacing / 2
val barWidth = (chartWidth - totalSpacing) / barCount val barWidth = (chartWidth - totalSpacing) / barCount
@@ -106,25 +106,25 @@ fun BarChart(
size = androidx.compose.ui.geometry.Size(barWidth, barHeight) 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) { if (dataPoint.value > 0) {
val valueText = dataPoint.value.toString() val valueText = dataPoint.value.toString()
val textStyle = TextStyle(color = style.textColor, fontSize = 10.sp) val textStyle = TextStyle(color = style.textColor, fontSize = 10.sp)
val textSize = textMeasurer.measure(valueText, textStyle) val textSize = textMeasurer.measure(valueText, textStyle)
// Position text on top of bar or inside if bar is tall enough // Position text
val textY = val textY =
if (barHeight > textSize.size.height + 8.dp.toPx()) { if (barHeight > textSize.size.height + 8.dp.toPx()) {
barY + 8.dp.toPx() // Inside bar barY + 8.dp.toPx()
} else { } else {
barY - 4.dp.toPx() // Above bar barY - 4.dp.toPx()
} }
val textColor = val textColor =
if (barHeight > textSize.size.height + 8.dp.toPx()) { if (barHeight > textSize.size.height + 8.dp.toPx()) {
Color.White // White text inside bar Color.White
} else { } else {
style.textColor // Regular color above bar style.textColor
} }
drawText( drawText(
@@ -166,7 +166,7 @@ private fun DrawScope.drawGrid(
) { ) {
val textStyle = TextStyle(color = textColor, fontSize = 10.sp) val textStyle = TextStyle(color = textColor, fontSize = 10.sp)
// Draw horizontal grid lines (Y-axis) // Horizontal grid lines
val gridLines = val gridLines =
when { when {
maxValue <= 5 -> (0..maxValue).toList() maxValue <= 5 -> (0..maxValue).toList()

View File

@@ -6,40 +6,26 @@ import java.time.format.DateTimeFormatter
object DateFormatUtils { object DateFormatUtils {
/** // ISO 8601 formatter matching iOS date format exactly
* ISO 8601 formatter matching iOS date format exactly Produces dates like:
* "2025-09-07T22:00:40.014Z"
*/
private val ISO_FORMATTER = private val ISO_FORMATTER =
DateTimeFormatter.ofPattern("yyyy-MM-dd'T'HH:mm:ss.SSSX").withZone(ZoneOffset.UTC) DateTimeFormatter.ofPattern("yyyy-MM-dd'T'HH:mm:ss.SSSX").withZone(ZoneOffset.UTC)
/** /** Get current timestamp in iOS-compatible ISO 8601 format */
* Get current timestamp in iOS-compatible ISO 8601 format
* @return Current timestamp as "2025-09-07T22:00:40.014Z"
*/
fun nowISO8601(): String { fun nowISO8601(): String {
return ISO_FORMATTER.format(Instant.now()) return ISO_FORMATTER.format(Instant.now())
} }
/** /** Format an Instant to iOS-compatible ISO 8601 format */
* 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"
*/
fun formatISO8601(instant: Instant): String { fun formatISO8601(instant: Instant): String {
return ISO_FORMATTER.format(instant) return ISO_FORMATTER.format(instant)
} }
/** /** Parse an iOS-compatible ISO 8601 date string back to 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
*/
fun parseISO8601(dateString: String): Instant? { fun parseISO8601(dateString: String): Instant? {
return try { return try {
Instant.from(ISO_FORMATTER.parse(dateString)) Instant.from(ISO_FORMATTER.parse(dateString))
} catch (e: Exception) { } catch (e: Exception) {
// Fallback - try standard Instant parsing
try { try {
Instant.parse(dateString) Instant.parse(dateString)
} catch (e2: Exception) { } catch (e2: Exception) {
@@ -48,20 +34,12 @@ object DateFormatUtils {
} }
} }
/** /** Validate that a date string matches the expected iOS format */
* 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
*/
fun isValidISO8601(dateString: String): Boolean { fun isValidISO8601(dateString: String): Boolean {
return parseISO8601(dateString) != null return parseISO8601(dateString) != null
} }
/** /** Convert milliseconds timestamp to iOS-compatible ISO 8601 format */
* Convert milliseconds timestamp to iOS-compatible ISO 8601 format
* @param millis Milliseconds since epoch
* @return Formatted timestamp as "2025-09-07T22:00:40.014Z"
*/
fun millisToISO8601(millis: Long): String { fun millisToISO8601(millis: Long): String {
return ISO_FORMATTER.format(Instant.ofEpochMilli(millis)) return ISO_FORMATTER.format(Instant.ofEpochMilli(millis))
} }

View File

@@ -12,15 +12,7 @@ object ImageNamingUtils {
private const val IMAGE_EXTENSION = ".jpg" private const val IMAGE_EXTENSION = ".jpg"
private const val HASH_LENGTH = 12 // First 12 chars of SHA-256 private const val HASH_LENGTH = 12 // First 12 chars of SHA-256
/** /** Generates a deterministic filename for a problem image */
* 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
*/
fun generateImageFilename(problemId: String, timestamp: String, imageIndex: Int): String { fun generateImageFilename(problemId: String, timestamp: String, imageIndex: Int): String {
// Create a deterministic hash from problemId + timestamp + index // Create a deterministic hash from problemId + timestamp + index
val input = "${problemId}_${timestamp}_${imageIndex}" val input = "${problemId}_${timestamp}_${imageIndex}"
@@ -29,25 +21,13 @@ object ImageNamingUtils {
return "problem_${hash}_${imageIndex}${IMAGE_EXTENSION}" return "problem_${hash}_${imageIndex}${IMAGE_EXTENSION}"
} }
/** /** Generates a deterministic filename using current timestamp */
* 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
*/
fun generateImageFilename(problemId: String, imageIndex: Int): String { fun generateImageFilename(problemId: String, imageIndex: Int): String {
val timestamp = DateFormatUtils.nowISO8601() val timestamp = DateFormatUtils.nowISO8601()
return generateImageFilename(problemId, timestamp, imageIndex) return generateImageFilename(problemId, timestamp, imageIndex)
} }
/** /** Extracts problem ID from an image filename */
* 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
*/
fun extractProblemIdFromFilename(filename: String): String? { fun extractProblemIdFromFilename(filename: String): String? {
if (!filename.startsWith("problem_") || !filename.endsWith(IMAGE_EXTENSION)) { if (!filename.startsWith("problem_") || !filename.endsWith(IMAGE_EXTENSION)) {
return null return null
@@ -66,12 +46,7 @@ object ImageNamingUtils {
return parts[1] // Return the hash as identifier return parts[1] // Return the hash as identifier
} }
/** /** Validates if a filename follows our naming convention */
* Validates if a filename follows our naming convention.
*
* @param filename The filename to validate
* @return true if it matches our convention, false otherwise
*/
fun isValidImageFilename(filename: String): Boolean { fun isValidImageFilename(filename: String): Boolean {
if (!filename.startsWith("problem_") || !filename.endsWith(IMAGE_EXTENSION)) { if (!filename.startsWith("problem_") || !filename.endsWith(IMAGE_EXTENSION)) {
return false return false
@@ -86,15 +61,7 @@ object ImageNamingUtils {
parts[2].toIntOrNull() != null parts[2].toIntOrNull() != null
} }
/** /** Migrates an existing filename to our naming convention */
* 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
*/
fun migrateFilename(oldFilename: String, problemId: String, imageIndex: Int): String { fun migrateFilename(oldFilename: String, problemId: String, imageIndex: Int): String {
// If it's already using our convention, keep it // If it's already using our convention, keep it
if (isValidImageFilename(oldFilename)) { if (isValidImageFilename(oldFilename)) {
@@ -107,13 +74,7 @@ object ImageNamingUtils {
return generateImageFilename(problemId, timestamp, imageIndex) return generateImageFilename(problemId, timestamp, imageIndex)
} }
/** /** Creates a deterministic hash from input string */
* 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
*/
private fun createHash(input: String): String { private fun createHash(input: String): String {
val digest = MessageDigest.getInstance("SHA-256") val digest = MessageDigest.getInstance("SHA-256")
val hashBytes = digest.digest(input.toByteArray(Charsets.UTF_8)) val hashBytes = digest.digest(input.toByteArray(Charsets.UTF_8))
@@ -121,14 +82,7 @@ object ImageNamingUtils {
return hashHex.take(HASH_LENGTH) return hashHex.take(HASH_LENGTH)
} }
/** /** Batch renames images for a problem to use our naming convention */
* 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
*/
fun batchRenameForProblem( fun batchRenameForProblem(
problemId: String, problemId: String,
existingFilenames: List<String> existingFilenames: List<String>

View File

@@ -16,7 +16,7 @@ object ImageUtils {
private const val MAX_IMAGE_SIZE = 1024 private const val MAX_IMAGE_SIZE = 1024
private const val IMAGE_QUALITY = 85 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 { private fun getImagesDirectory(context: Context): File {
val imagesDir = File(context.filesDir, IMAGES_DIR) val imagesDir = File(context.filesDir, IMAGES_DIR)
if (!imagesDir.exists()) { if (!imagesDir.exists()) {
@@ -25,14 +25,7 @@ object ImageUtils {
return imagesDir return imagesDir
} }
/** /** Saves an image from a URI with compression and proper orientation */
* 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
*/
fun saveImageFromUri( fun saveImageFromUri(
context: Context, context: Context,
imageUri: Uri, imageUri: Uri,
@@ -40,7 +33,7 @@ object ImageUtils {
imageIndex: Int? = null imageIndex: Int? = null
): String? { ): String? {
return try { return try {
// Decode bitmap from a fresh stream to avoid mark/reset dependency
val originalBitmap = val originalBitmap =
context.contentResolver.openInputStream(imageUri)?.use { input -> context.contentResolver.openInputStream(imageUri)?.use { input ->
BitmapFactory.decodeStream(input) BitmapFactory.decodeStream(input)
@@ -50,7 +43,6 @@ object ImageUtils {
val orientedBitmap = correctImageOrientation(context, imageUri, originalBitmap) val orientedBitmap = correctImageOrientation(context, imageUri, originalBitmap)
val compressedBitmap = compressImage(orientedBitmap) val compressedBitmap = compressImage(orientedBitmap)
// Generate filename using naming convention if problem info provided
val filename = val filename =
if (problemId != null && imageIndex != null) { if (problemId != null && imageIndex != null) {
ImageNamingUtils.generateImageFilename(problemId, imageIndex) ImageNamingUtils.generateImageFilename(problemId, imageIndex)
@@ -59,19 +51,16 @@ object ImageUtils {
} }
val imageFile = File(getImagesDirectory(context), filename) val imageFile = File(getImagesDirectory(context), filename)
// Save compressed image
FileOutputStream(imageFile).use { output -> FileOutputStream(imageFile).use { output ->
compressedBitmap.compress(Bitmap.CompressFormat.JPEG, IMAGE_QUALITY, output) compressedBitmap.compress(Bitmap.CompressFormat.JPEG, IMAGE_QUALITY, output)
} }
// Clean up bitmaps
originalBitmap.recycle() originalBitmap.recycle()
if (orientedBitmap != originalBitmap) { if (orientedBitmap != originalBitmap) {
orientedBitmap.recycle() orientedBitmap.recycle()
} }
compressedBitmap.recycle() compressedBitmap.recycle()
// Return relative path
"$IMAGES_DIR/$filename" "$IMAGES_DIR/$filename"
} catch (e: Exception) { } catch (e: Exception) {
e.printStackTrace() e.printStackTrace()
@@ -162,12 +151,7 @@ object ImageUtils {
} }
} }
/** /** Gets the full file path for an image */
* Gets the full file path for an image
* @param context Android context
* @param relativePath The relative path returned by saveImageFromUri
* @return Full file path
*/
fun getImageFile(context: Context, relativePath: String): File { fun getImageFile(context: Context, relativePath: String): File {
// If relativePath already contains the directory, use it as-is // If relativePath already contains the directory, use it as-is
// Otherwise, assume it's just a filename and add the images directory // Otherwise, assume it's just a filename and add the images directory
@@ -179,12 +163,7 @@ object ImageUtils {
} }
} }
/** /** Deletes an image file */
* 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
*/
fun deleteImage(context: Context, relativePath: String): Boolean { fun deleteImage(context: Context, relativePath: String): Boolean {
return try { return try {
val file = getImageFile(context, relativePath) val file = getImageFile(context, relativePath)
@@ -195,12 +174,7 @@ object ImageUtils {
} }
} }
/** /** Imports an image file from the import directory */
* 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
*/
fun importImageFile(context: Context, sourceFile: File): String? { fun importImageFile(context: Context, sourceFile: File): String? {
return try { return try {
if (!sourceFile.exists()) return null if (!sourceFile.exists()) return null
@@ -218,11 +192,7 @@ object ImageUtils {
} }
} }
/** /** Gets all image files in the images directory */
* Gets all image files in the images directory
* @param context Android context
* @return List of relative paths for all images
*/
fun getAllImages(context: Context): List<String> { fun getAllImages(context: Context): List<String> {
return try { return try {
val imagesDir = getImagesDirectory(context) val imagesDir = getImagesDirectory(context)
@@ -242,12 +212,7 @@ object ImageUtils {
} }
} }
/** /** Saves an image from byte array to app's private storage */
* 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
*/
fun saveImageFromBytes(context: Context, imageData: ByteArray): String? { fun saveImageFromBytes(context: Context, imageData: ByteArray): String? {
return try { return try {
val bitmap = BitmapFactory.decodeByteArray(imageData, 0, imageData.size) ?: return null val bitmap = BitmapFactory.decodeByteArray(imageData, 0, imageData.size) ?: return null
@@ -275,13 +240,7 @@ object ImageUtils {
} }
} }
/** /** Saves image data with a specific filename */
* 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
*/
fun saveImageFromBytesWithFilename( fun saveImageFromBytesWithFilename(
context: Context, context: Context,
imageData: ByteArray, imageData: ByteArray,
@@ -312,13 +271,7 @@ object ImageUtils {
} }
} }
/** /** Migrates existing images to use consistent naming convention */
* 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
*/
fun migrateImageNaming( fun migrateImageNaming(
context: Context, context: Context,
problemId: String, problemId: String,
@@ -349,12 +302,7 @@ object ImageUtils {
return migrationMap return migrationMap
} }
/** /** Batch migrates all images in the system to use consistent naming */
* 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
*/
fun batchMigrateAllImages( fun batchMigrateAllImages(
context: Context, context: Context,
problemImageMap: Map<String, List<String>> problemImageMap: Map<String, List<String>>
@@ -369,11 +317,7 @@ object ImageUtils {
return allMigrations return allMigrations
} }
/** /** Cleans up orphaned images that are not referenced by any problems */
* 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
*/
fun cleanupOrphanedImages(context: Context, referencedPaths: Set<String>) { fun cleanupOrphanedImages(context: Context, referencedPaths: Set<String>) {
try { try {
val allImages = getAllImages(context) val allImages = getAllImages(context)

View File

@@ -481,10 +481,7 @@ object SessionShareUtils {
action = Intent.ACTION_SEND action = Intent.ACTION_SEND
type = "image/png" type = "image/png"
putExtra(Intent.EXTRA_STREAM, uri) putExtra(Intent.EXTRA_STREAM, uri)
putExtra( putExtra(Intent.EXTRA_TEXT, "Check out my climbing session! #OpenClimb")
Intent.EXTRA_TEXT,
"Check out my climbing session! 🧗‍♀️ #OpenClimb"
)
addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION) addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
} }

View File

@@ -20,14 +20,7 @@ object ZipExportImportUtils {
private const val IMAGES_DIR_NAME = "images" private const val IMAGES_DIR_NAME = "images"
private const val METADATA_FILENAME = "metadata.txt" private const val METADATA_FILENAME = "metadata.txt"
/** /** Creates a ZIP file containing the JSON data and all referenced images */
* 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
*/
fun createExportZip( fun createExportZip(
context: Context, context: Context,
exportData: ClimbDataBackup, exportData: ClimbDataBackup,
@@ -120,13 +113,7 @@ object ZipExportImportUtils {
} }
} }
/** /** Creates a ZIP file and writes it to a provided URI */
* 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
*/
fun createExportZipToUri( fun createExportZipToUri(
context: Context, context: Context,
uri: android.net.Uri, uri: android.net.Uri,
@@ -214,12 +201,7 @@ object ZipExportImportUtils {
val importedImagePaths: Map<String, String> // original filename -> new relative path val importedImagePaths: Map<String, String> // original filename -> new relative path
) )
/** /** Extracts a ZIP file and returns the JSON content and imported image paths */
* 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
*/
fun extractImportZip(context: Context, zipFile: File): ImportResult { fun extractImportZip(context: Context, zipFile: File): ImportResult {
var jsonContent = "" var jsonContent = ""
val importedImagePaths = mutableMapOf<String, String>() val importedImagePaths = mutableMapOf<String, String>()

View File

@@ -0,0 +1,603 @@
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
/**
* Business logic and integration tests for OpenClimb Android app. Tests complex workflows, business
* rules, and data relationships.
*/
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<Attempt>
): 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<Attempt>,
problems: List<Problem>
): 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<Gym>,
problems: List<Problem>,
sessions: List<ClimbSession>,
attempts: List<Attempt>
): 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<String>): List<String> {
return tags.map { it.trim().lowercase() }.filter { it.isNotEmpty() }
}
private fun convertToRelativePaths(paths: List<String>): List<String> {
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<Attempt>) {
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() }
}
}
}

View File

@@ -0,0 +1,575 @@
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
/**
* Comprehensive unit tests for OpenClimb Android data models and utilities. These tests verify core
* functionality without requiring Android context.
*/
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))
}
}

View File

@@ -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)
}
}

View File

@@ -67,7 +67,7 @@ class SyncMergeLogicTest {
id = "attempt1", id = "attempt1",
sessionId = "session1", sessionId = "session1",
problemId = "problem1", problemId = "problem1",
result = AttemptResult.COMPLETED, result = AttemptResult.SUCCESS,
highestHold = null, highestHold = null,
notes = null, notes = null,
duration = 300, duration = 300,
@@ -96,7 +96,7 @@ class SyncMergeLogicTest {
id = "gym1", id = "gym1",
name = "Updated Gym 1", name = "Updated Gym 1",
location = "Updated Location", location = "Updated Location",
supportedClimbTypes = listOf(ClimbType.BOULDER, ClimbType.SPORT), supportedClimbTypes = listOf(ClimbType.BOULDER, ClimbType.ROPE),
difficultySystems = difficultySystems =
listOf(DifficultySystem.V_SCALE, DifficultySystem.YDS), listOf(DifficultySystem.V_SCALE, DifficultySystem.YDS),
customDifficultyGrades = emptyList(), customDifficultyGrades = emptyList(),
@@ -109,7 +109,7 @@ class SyncMergeLogicTest {
id = "gym2", id = "gym2",
name = "Server Gym 2", name = "Server Gym 2",
location = "Server Location", location = "Server Location",
supportedClimbTypes = listOf(ClimbType.TRAD), supportedClimbTypes = listOf(ClimbType.ROPE),
difficultySystems = listOf(DifficultySystem.YDS), difficultySystems = listOf(DifficultySystem.YDS),
customDifficultyGrades = emptyList(), customDifficultyGrades = emptyList(),
notes = null, notes = null,
@@ -143,7 +143,7 @@ class SyncMergeLogicTest {
gymId = "gym2", gymId = "gym2",
name = "Server Problem", name = "Server Problem",
description = "Server description", description = "Server description",
climbType = ClimbType.TRAD, climbType = ClimbType.ROPE,
difficulty = DifficultyGrade(DifficultySystem.YDS, "5.10a"), difficulty = DifficultyGrade(DifficultySystem.YDS, "5.10a"),
tags = listOf("server"), tags = listOf("server"),
location = null, location = null,
@@ -180,7 +180,7 @@ class SyncMergeLogicTest {
id = "attempt2", id = "attempt2",
sessionId = "session2", sessionId = "session2",
problemId = "problem2", problemId = "problem2",
result = AttemptResult.FELL, result = AttemptResult.FALL,
highestHold = "Last move", highestHold = "Last move",
notes = "Almost had it", notes = "Almost had it",
duration = 180, duration = 180,

View File

@@ -0,0 +1,374 @@
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
/**
* Comprehensive utility and service tests for OpenClimb Android app. Tests core utility functions,
* date handling, string operations, and business logic.
*/
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<AttemptData>): 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<String> {
return rawTags.split(",").map { it.trim().lowercase() }.filter { it.isNotEmpty() }
}
private fun filterByClimbType(
problems: List<ProblemData>,
climbType: String
): List<ProblemData> {
return problems.filter { it.climbType == climbType }
}
private fun filterByTag(problems: List<ProblemData>, tag: String): List<ProblemData> {
return problems.filter { it.tags.contains(tag) }
}
private fun filterByDifficultyRange(
problems: List<ProblemData>,
minGrade: String,
maxGrade: String
): List<ProblemData> {
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<String, String>,
server: Map<String, String>
): Map<String, String> {
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<String>
)
data class BackupData(
val version: String,
val formatVersion: String,
val exportedAt: String,
val dataCount: Int
)
}

View File

@@ -19,7 +19,7 @@ kotlinxSerialization = "1.9.0"
kotlinxCoroutines = "1.10.2" kotlinxCoroutines = "1.10.2"
coil = "2.7.0" coil = "2.7.0"
ksp = "2.2.10-2.0.2" ksp = "2.2.10-2.0.2"
okhttp = "4.12.0" okhttp = "5.1.0"
[libraries] [libraries]
androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" } 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" } kotlinx-coroutines-test = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-test", version.ref = "kotlinxCoroutines" }
# Testing # Testing
mockk = { group = "io.mockk", name = "mockk", version = "1.14.5" } mockk = { group = "io.mockk", name = "mockk", version = "1.14.6" }
# Image Loading # Image Loading
coil-compose = { group = "io.coil-kt", name = "coil-compose", version.ref = "coil" } 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] [plugins]
android-application = { id = "com.android.application", version.ref = "agp" } android-application = { id = "com.android.application", version.ref = "agp" }
kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" } kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" }

Binary file not shown.

View File

@@ -1,6 +1,7 @@
#Fri Aug 15 11:23:25 MDT 2025
distributionBase=GRADLE_USER_HOME distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists distributionPath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-8.13-bin.zip distributionUrl=https\://services.gradle.org/distributions/gradle-8.13-bin.zip
networkTimeout=10000
validateDistributionUrl=true
zipStoreBase=GRADLE_USER_HOME zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists zipStorePath=wrapper/dists

295
android/gradlew vendored
View File

@@ -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"); # Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with 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 # See the License for the specific language governing permissions and
# limitations under the License. # 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 # Attempt to set APP_HOME
# Resolve links: $0 may be a link # Resolve links: $0 may be a link
PRG="$0" app_path=$0
# Need this for relative symlinks.
while [ -h "$PRG" ] ; do # Need this for daisy-chained symlinks.
ls=`ls -ld "$PRG"` while
link=`expr "$ls" : '.*-> \(.*\)$'` APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path
if expr "$link" : '/.*' > /dev/null; then [ -h "$app_path" ]
PRG="$link" do
else ls=$( ls -ld "$app_path" )
PRG=`dirname "$PRG"`"/$link" link=${ls#*' -> '}
fi case $link in #(
/*) app_path=$link ;; #(
*) app_path=$APP_HOME$link ;;
esac
done done
SAVED="`pwd`"
cd "`dirname \"$PRG\"`/" >/dev/null
APP_HOME="`pwd -P`"
cd "$SAVED" >/dev/null
APP_NAME="Gradle" # This is normally unused
APP_BASE_NAME=`basename "$0"` # shellcheck disable=SC2034
APP_BASE_NAME=${0##*/}
# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. # Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036)
DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' 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. # Use the maximum available, or set MAX_FD != -1 to use that value.
MAX_FD="maximum" MAX_FD=maximum
warn () { warn () {
echo "$*" echo "$*"
} } >&2
die () { die () {
echo echo
echo "$*" echo "$*"
echo echo
exit 1 exit 1
} } >&2
# OS specific support (must be 'true' or 'false'). # OS specific support (must be 'true' or 'false').
cygwin=false cygwin=false
msys=false msys=false
darwin=false darwin=false
nonstop=false nonstop=false
case "`uname`" in case "$( uname )" in #(
CYGWIN* ) CYGWIN* ) cygwin=true ;; #(
cygwin=true Darwin* ) darwin=true ;; #(
;; MSYS* | MINGW* ) msys=true ;; #(
Darwin* ) NONSTOP* ) nonstop=true ;;
darwin=true
;;
MINGW* )
msys=true
;;
NONSTOP* )
nonstop=true
;;
esac esac
CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar
# Determine the Java command to use to start the JVM. # Determine the Java command to use to start the JVM.
if [ -n "$JAVA_HOME" ] ; then if [ -n "$JAVA_HOME" ] ; then
if [ -x "$JAVA_HOME/jre/sh/java" ] ; then if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
# IBM's JDK on AIX uses strange locations for the executables # IBM's JDK on AIX uses strange locations for the executables
JAVACMD="$JAVA_HOME/jre/sh/java" JAVACMD=$JAVA_HOME/jre/sh/java
else else
JAVACMD="$JAVA_HOME/bin/java" JAVACMD=$JAVA_HOME/bin/java
fi fi
if [ ! -x "$JAVACMD" ] ; then if [ ! -x "$JAVACMD" ] ; then
die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME 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." location of your Java installation."
fi fi
else else
JAVACMD="java" 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. 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 Please set the JAVA_HOME variable in your environment to match the
location of your Java installation." location of your Java installation."
fi fi
fi
# Increase the maximum file descriptors if we can. # Increase the maximum file descriptors if we can.
if [ "$cygwin" = "false" -a "$darwin" = "false" -a "$nonstop" = "false" ] ; then if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then
MAX_FD_LIMIT=`ulimit -H -n` case $MAX_FD in #(
if [ $? -eq 0 ] ; then max*)
if [ "$MAX_FD" = "maximum" -o "$MAX_FD" = "max" ] ; then # In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked.
MAX_FD="$MAX_FD_LIMIT" # shellcheck disable=SC2039,SC3045
fi MAX_FD=$( ulimit -H -n ) ||
ulimit -n $MAX_FD warn "Could not query maximum file descriptor limit"
if [ $? -ne 0 ] ; then esac
warn "Could not set maximum file descriptor limit: $MAX_FD" case $MAX_FD in #(
fi '' | soft) :;; #(
else *)
warn "Could not query maximum file descriptor limit: $MAX_FD_LIMIT" # In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked.
fi # shellcheck disable=SC2039,SC3045
fi ulimit -n "$MAX_FD" ||
warn "Could not set maximum file descriptor limit to $MAX_FD"
# 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" ;;
esac esac
fi fi
# Escape application args # Collect all arguments for the java command, stacking in reverse order:
save () { # * args from the command line
for i do printf %s\\n "$i" | sed "s/'/'\\\\''/g;1s/^/'/;\$s/\$/' \\\\/" ; done # * the main class name
echo " " # * -classpath
} # * -D...appname settings
APP_ARGS=`save "$@"` # * --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 # For Cygwin or MSYS, switch paths to Windows format before running java
eval set -- $DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS "\"-Dorg.gradle.appname=$APP_BASE_NAME\"" -classpath "\"$CLASSPATH\"" org.gradle.wrapper.GradleWrapperMain "$APP_ARGS" 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" "$@" exec "$JAVACMD" "$@"

36
android/gradlew.bat vendored
View File

@@ -13,6 +13,8 @@
@rem See the License for the specific language governing permissions and @rem See the License for the specific language governing permissions and
@rem limitations under the License. @rem limitations under the License.
@rem @rem
@rem SPDX-License-Identifier: Apache-2.0
@rem
@if "%DEBUG%"=="" @echo off @if "%DEBUG%"=="" @echo off
@rem ########################################################################## @rem ##########################################################################
@@ -26,6 +28,7 @@ if "%OS%"=="Windows_NT" setlocal
set DIRNAME=%~dp0 set DIRNAME=%~dp0
if "%DIRNAME%"=="" set DIRNAME=. if "%DIRNAME%"=="" set DIRNAME=.
@rem This is normally unused
set APP_BASE_NAME=%~n0 set APP_BASE_NAME=%~n0
set APP_HOME=%DIRNAME% set APP_HOME=%DIRNAME%
@@ -40,13 +43,13 @@ if defined JAVA_HOME goto findJavaFromJavaHome
set JAVA_EXE=java.exe set JAVA_EXE=java.exe
%JAVA_EXE% -version >NUL 2>&1 %JAVA_EXE% -version >NUL 2>&1
if "%ERRORLEVEL%" == "0" goto execute if %ERRORLEVEL% equ 0 goto execute
echo. echo. 1>&2
echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2
echo. echo. 1>&2
echo Please set the JAVA_HOME variable in your environment to match the echo Please set the JAVA_HOME variable in your environment to match the 1>&2
echo location of your Java installation. echo location of your Java installation. 1>&2
goto fail goto fail
@@ -56,32 +59,33 @@ set JAVA_EXE=%JAVA_HOME%/bin/java.exe
if exist "%JAVA_EXE%" goto execute if exist "%JAVA_EXE%" goto execute
echo. echo. 1>&2
echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2
echo. echo. 1>&2
echo Please set the JAVA_HOME variable in your environment to match the echo Please set the JAVA_HOME variable in your environment to match the 1>&2
echo location of your Java installation. echo location of your Java installation. 1>&2
goto fail goto fail
:execute :execute
@rem Setup the command line @rem Setup the command line
set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar
@rem Execute Gradle @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 :end
@rem End local scope for the variables with windows NT shell @rem End local scope for the variables with windows NT shell
if "%ERRORLEVEL%"=="0" goto mainEnd if %ERRORLEVEL% equ 0 goto mainEnd
:fail :fail
rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of
rem the _cmd.exe /c_ return code! rem the _cmd.exe /c_ return code!
if not "" == "%GRADLE_EXIT_CONSOLE%" exit 1 set EXIT_CODE=%ERRORLEVEL%
exit /b 1 if %EXIT_CODE% equ 0 set EXIT_CODE=1
if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE%
exit /b %EXIT_CODE%
:mainEnd :mainEnd
if "%OS%"=="Windows_NT" endlocal if "%OS%"=="Windows_NT" endlocal

View File

@@ -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<List<Gym>> = 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<List<Gym>> = gymDao.searchGyms(query)
// Problem operations
fun getAllProblems(): Flow<List<Problem>> = problemDao.getAllProblems()
suspend fun getProblemById(id: String): Problem? = problemDao.getProblemById(id)
fun getProblemsByGym(gymId: String): Flow<List<Problem>> = 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<List<Problem>> = problemDao.searchProblems(query)
// Session operations
fun getAllSessions(): Flow<List<ClimbSession>> = sessionDao.getAllSessions()
suspend fun getSessionById(id: String): ClimbSession? = sessionDao.getSessionById(id)
fun getSessionsByGym(gymId: String): Flow<List<ClimbSession>> =
sessionDao.getSessionsByGym(gymId)
suspend fun getActiveSession(): ClimbSession? = sessionDao.getActiveSession()
fun getActiveSessionFlow(): Flow<ClimbSession?> = 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<List<Attempt>> = attemptDao.getAllAttempts()
fun getAttemptsBySession(sessionId: String): Flow<List<Attempt>> =
attemptDao.getAttemptsBySession(sessionId)
fun getAttemptsByProblem(problemId: String): Flow<List<Attempt>> =
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<ClimbDataBackup>(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<Gym>,
problems: List<Problem>,
sessions: List<ClimbSession>,
attempts: List<Attempt>
) {
// 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}")
}
}
}

View File

@@ -20,7 +20,7 @@ struct ClimbingActivityWidget: Widget {
DynamicIsland { DynamicIsland {
// Expanded UI goes here // Expanded UI goes here
DynamicIslandExpandedRegion(.leading) { DynamicIslandExpandedRegion(.leading) {
Text("🧗‍♂️") Text("CLIMB")
.font(.title2) .font(.title2)
} }
DynamicIslandExpandedRegion(.trailing) { DynamicIslandExpandedRegion(.trailing) {
@@ -39,12 +39,12 @@ struct ClimbingActivityWidget: Widget {
.font(.caption) .font(.caption)
} }
} compactLeading: { } compactLeading: {
Text("🧗‍♂️") Text("CLIMB")
} compactTrailing: { } compactTrailing: {
Text("\(context.state.totalAttempts)") Text("\(context.state.totalAttempts)")
.monospacedDigit() .monospacedDigit()
} minimal: { } minimal: {
Text("🧗‍♂️") Text("CLIMB")
} }
} }
} }
@@ -56,7 +56,7 @@ struct LiveActivityView: View {
var body: some View { var body: some View {
VStack(alignment: .leading, spacing: 8) { VStack(alignment: .leading, spacing: 8) {
HStack { HStack {
Text("🧗‍♂️ \(context.attributes.gymName)") Text("CLIMBING: \(context.attributes.gymName)")
.font(.headline) .font(.headline)
.lineLimit(1) .lineLimit(1)
Spacer() Spacer()

View File

@@ -15,6 +15,13 @@
/* End PBXBuildFile section */ /* End PBXBuildFile section */
/* Begin PBXContainerItemProxy section */ /* Begin PBXContainerItemProxy section */
D2F32FB12E90B26500B1BC56 /* PBXContainerItemProxy */ = {
isa = PBXContainerItemProxy;
containerPortal = D24C19602E75002A0045894C /* Project object */;
proxyType = 1;
remoteGlobalIDString = D24C19672E75002A0045894C;
remoteInfo = OpenClimb;
};
D2FE949E2E78FEE1008CDB25 /* PBXContainerItemProxy */ = { D2FE949E2E78FEE1008CDB25 /* PBXContainerItemProxy */ = {
isa = PBXContainerItemProxy; isa = PBXContainerItemProxy;
containerPortal = D24C19602E75002A0045894C /* Project object */; containerPortal = D24C19602E75002A0045894C /* Project object */;
@@ -41,6 +48,7 @@
/* Begin PBXFileReference section */ /* Begin PBXFileReference section */
D24C19682E75002A0045894C /* OpenClimb.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = OpenClimb.app; sourceTree = BUILT_PRODUCTS_DIR; }; 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 = "<group>"; }; D268B79E2E83894A003AA641 /* SessionStatusLiveExtension.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = SessionStatusLiveExtension.entitlements; sourceTree = "<group>"; };
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; }; 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; }; 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; }; 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; path = OpenClimb;
sourceTree = "<group>"; sourceTree = "<group>";
}; };
D2F32FAE2E90B26500B1BC56 /* OpenClimbTests */ = {
isa = PBXFileSystemSynchronizedRootGroup;
path = OpenClimbTests;
sourceTree = "<group>";
};
D2FE94902E78FEE0008CDB25 /* SessionStatusLive */ = { D2FE94902E78FEE0008CDB25 /* SessionStatusLive */ = {
isa = PBXFileSystemSynchronizedRootGroup; isa = PBXFileSystemSynchronizedRootGroup;
exceptions = ( exceptions = (
@@ -92,6 +105,13 @@
); );
runOnlyForDeploymentPostprocessing = 0; runOnlyForDeploymentPostprocessing = 0;
}; };
D2F32FAA2E90B26500B1BC56 /* Frameworks */ = {
isa = PBXFrameworksBuildPhase;
buildActionMask = 2147483647;
files = (
);
runOnlyForDeploymentPostprocessing = 0;
};
D2FE94882E78FEE0008CDB25 /* Frameworks */ = { D2FE94882E78FEE0008CDB25 /* Frameworks */ = {
isa = PBXFrameworksBuildPhase; isa = PBXFrameworksBuildPhase;
buildActionMask = 2147483647; buildActionMask = 2147483647;
@@ -111,6 +131,7 @@
D268B79E2E83894A003AA641 /* SessionStatusLiveExtension.entitlements */, D268B79E2E83894A003AA641 /* SessionStatusLiveExtension.entitlements */,
D24C196A2E75002A0045894C /* OpenClimb */, D24C196A2E75002A0045894C /* OpenClimb */,
D2FE94902E78FEE0008CDB25 /* SessionStatusLive */, D2FE94902E78FEE0008CDB25 /* SessionStatusLive */,
D2F32FAE2E90B26500B1BC56 /* OpenClimbTests */,
D2FE947F2E78E958008CDB25 /* Frameworks */, D2FE947F2E78E958008CDB25 /* Frameworks */,
D24C19692E75002A0045894C /* Products */, D24C19692E75002A0045894C /* Products */,
); );
@@ -121,6 +142,7 @@
children = ( children = (
D24C19682E75002A0045894C /* OpenClimb.app */, D24C19682E75002A0045894C /* OpenClimb.app */,
D2FE948B2E78FEE0008CDB25 /* SessionStatusLiveExtension.appex */, D2FE948B2E78FEE0008CDB25 /* SessionStatusLiveExtension.appex */,
D2F32FAD2E90B26500B1BC56 /* OpenClimbTests.xctest */,
); );
name = Products; name = Products;
sourceTree = "<group>"; sourceTree = "<group>";
@@ -162,6 +184,29 @@
productReference = D24C19682E75002A0045894C /* OpenClimb.app */; productReference = D24C19682E75002A0045894C /* OpenClimb.app */;
productType = "com.apple.product-type.application"; 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 */ = { D2FE948A2E78FEE0008CDB25 /* SessionStatusLiveExtension */ = {
isa = PBXNativeTarget; isa = PBXNativeTarget;
buildConfigurationList = D2FE94A12E78FEE1008CDB25 /* Build configuration list for PBXNativeTarget "SessionStatusLiveExtension" */; buildConfigurationList = D2FE94A12E78FEE1008CDB25 /* Build configuration list for PBXNativeTarget "SessionStatusLiveExtension" */;
@@ -197,6 +242,10 @@
D24C19672E75002A0045894C = { D24C19672E75002A0045894C = {
CreatedOnToolsVersion = 26.0; CreatedOnToolsVersion = 26.0;
}; };
D2F32FAC2E90B26500B1BC56 = {
CreatedOnToolsVersion = 26.0.1;
TestTargetID = D24C19672E75002A0045894C;
};
D2FE948A2E78FEE0008CDB25 = { D2FE948A2E78FEE0008CDB25 = {
CreatedOnToolsVersion = 26.0; CreatedOnToolsVersion = 26.0;
}; };
@@ -218,6 +267,7 @@
targets = ( targets = (
D24C19672E75002A0045894C /* OpenClimb */, D24C19672E75002A0045894C /* OpenClimb */,
D2FE948A2E78FEE0008CDB25 /* SessionStatusLiveExtension */, D2FE948A2E78FEE0008CDB25 /* SessionStatusLiveExtension */,
D2F32FAC2E90B26500B1BC56 /* OpenClimbTests */,
); );
}; };
/* End PBXProject section */ /* End PBXProject section */
@@ -230,6 +280,13 @@
); );
runOnlyForDeploymentPostprocessing = 0; runOnlyForDeploymentPostprocessing = 0;
}; };
D2F32FAB2E90B26500B1BC56 /* Resources */ = {
isa = PBXResourcesBuildPhase;
buildActionMask = 2147483647;
files = (
);
runOnlyForDeploymentPostprocessing = 0;
};
D2FE94892E78FEE0008CDB25 /* Resources */ = { D2FE94892E78FEE0008CDB25 /* Resources */ = {
isa = PBXResourcesBuildPhase; isa = PBXResourcesBuildPhase;
buildActionMask = 2147483647; buildActionMask = 2147483647;
@@ -247,6 +304,13 @@
); );
runOnlyForDeploymentPostprocessing = 0; runOnlyForDeploymentPostprocessing = 0;
}; };
D2F32FA92E90B26500B1BC56 /* Sources */ = {
isa = PBXSourcesBuildPhase;
buildActionMask = 2147483647;
files = (
);
runOnlyForDeploymentPostprocessing = 0;
};
D2FE94872E78FEE0008CDB25 /* Sources */ = { D2FE94872E78FEE0008CDB25 /* Sources */ = {
isa = PBXSourcesBuildPhase; isa = PBXSourcesBuildPhase;
buildActionMask = 2147483647; buildActionMask = 2147483647;
@@ -257,6 +321,11 @@
/* End PBXSourcesBuildPhase section */ /* End PBXSourcesBuildPhase section */
/* Begin PBXTargetDependency section */ /* Begin PBXTargetDependency section */
D2F32FB22E90B26500B1BC56 /* PBXTargetDependency */ = {
isa = PBXTargetDependency;
target = D24C19672E75002A0045894C /* OpenClimb */;
targetProxy = D2F32FB12E90B26500B1BC56 /* PBXContainerItemProxy */;
};
D2FE949F2E78FEE1008CDB25 /* PBXTargetDependency */ = { D2FE949F2E78FEE1008CDB25 /* PBXTargetDependency */ = {
isa = PBXTargetDependency; isa = PBXTargetDependency;
target = D2FE948A2E78FEE0008CDB25 /* SessionStatusLiveExtension */; target = D2FE948A2E78FEE0008CDB25 /* SessionStatusLiveExtension */;
@@ -396,7 +465,7 @@
CODE_SIGN_ENTITLEMENTS = OpenClimb/OpenClimb.entitlements; CODE_SIGN_ENTITLEMENTS = OpenClimb/OpenClimb.entitlements;
CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic; CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 12; CURRENT_PROJECT_VERSION = 13;
DEVELOPMENT_TEAM = 4BC9Y2LL4B; DEVELOPMENT_TEAM = 4BC9Y2LL4B;
ENABLE_PREVIEWS = YES; ENABLE_PREVIEWS = YES;
GENERATE_INFOPLIST_FILE = YES; GENERATE_INFOPLIST_FILE = YES;
@@ -416,7 +485,7 @@
"$(inherited)", "$(inherited)",
"@executable_path/Frameworks", "@executable_path/Frameworks",
); );
MARKETING_VERSION = 1.2.1; MARKETING_VERSION = 1.2.2;
PRODUCT_BUNDLE_IDENTIFIER = com.atridad.OpenClimb; PRODUCT_BUNDLE_IDENTIFIER = com.atridad.OpenClimb;
PRODUCT_NAME = "$(TARGET_NAME)"; PRODUCT_NAME = "$(TARGET_NAME)";
PROVISIONING_PROFILE_SPECIFIER = ""; PROVISIONING_PROFILE_SPECIFIER = "";
@@ -439,7 +508,7 @@
CODE_SIGN_ENTITLEMENTS = OpenClimb/OpenClimb.entitlements; CODE_SIGN_ENTITLEMENTS = OpenClimb/OpenClimb.entitlements;
CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic; CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 12; CURRENT_PROJECT_VERSION = 13;
DEVELOPMENT_TEAM = 4BC9Y2LL4B; DEVELOPMENT_TEAM = 4BC9Y2LL4B;
ENABLE_PREVIEWS = YES; ENABLE_PREVIEWS = YES;
GENERATE_INFOPLIST_FILE = YES; GENERATE_INFOPLIST_FILE = YES;
@@ -459,7 +528,7 @@
"$(inherited)", "$(inherited)",
"@executable_path/Frameworks", "@executable_path/Frameworks",
); );
MARKETING_VERSION = 1.2.1; MARKETING_VERSION = 1.2.2;
PRODUCT_BUNDLE_IDENTIFIER = com.atridad.OpenClimb; PRODUCT_BUNDLE_IDENTIFIER = com.atridad.OpenClimb;
PRODUCT_NAME = "$(TARGET_NAME)"; PRODUCT_NAME = "$(TARGET_NAME)";
PROVISIONING_PROFILE_SPECIFIER = ""; PROVISIONING_PROFILE_SPECIFIER = "";
@@ -474,6 +543,48 @@
}; };
name = Release; 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 */ = { D2FE94A22E78FEE1008CDB25 /* Debug */ = {
isa = XCBuildConfiguration; isa = XCBuildConfiguration;
buildSettings = { buildSettings = {
@@ -481,7 +592,7 @@
ASSETCATALOG_COMPILER_WIDGET_BACKGROUND_COLOR_NAME = WidgetBackground; ASSETCATALOG_COMPILER_WIDGET_BACKGROUND_COLOR_NAME = WidgetBackground;
CODE_SIGN_ENTITLEMENTS = SessionStatusLiveExtension.entitlements; CODE_SIGN_ENTITLEMENTS = SessionStatusLiveExtension.entitlements;
CODE_SIGN_STYLE = Automatic; CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 12; CURRENT_PROJECT_VERSION = 13;
DEVELOPMENT_TEAM = 4BC9Y2LL4B; DEVELOPMENT_TEAM = 4BC9Y2LL4B;
GENERATE_INFOPLIST_FILE = YES; GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_FILE = SessionStatusLive/Info.plist; INFOPLIST_FILE = SessionStatusLive/Info.plist;
@@ -492,7 +603,7 @@
"@executable_path/Frameworks", "@executable_path/Frameworks",
"@executable_path/../../Frameworks", "@executable_path/../../Frameworks",
); );
MARKETING_VERSION = 1.2.1; MARKETING_VERSION = 1.2.2;
PRODUCT_BUNDLE_IDENTIFIER = com.atridad.OpenClimb.SessionStatusLive; PRODUCT_BUNDLE_IDENTIFIER = com.atridad.OpenClimb.SessionStatusLive;
PRODUCT_NAME = "$(TARGET_NAME)"; PRODUCT_NAME = "$(TARGET_NAME)";
SKIP_INSTALL = YES; SKIP_INSTALL = YES;
@@ -511,7 +622,7 @@
ASSETCATALOG_COMPILER_WIDGET_BACKGROUND_COLOR_NAME = WidgetBackground; ASSETCATALOG_COMPILER_WIDGET_BACKGROUND_COLOR_NAME = WidgetBackground;
CODE_SIGN_ENTITLEMENTS = SessionStatusLiveExtension.entitlements; CODE_SIGN_ENTITLEMENTS = SessionStatusLiveExtension.entitlements;
CODE_SIGN_STYLE = Automatic; CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 12; CURRENT_PROJECT_VERSION = 13;
DEVELOPMENT_TEAM = 4BC9Y2LL4B; DEVELOPMENT_TEAM = 4BC9Y2LL4B;
GENERATE_INFOPLIST_FILE = YES; GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_FILE = SessionStatusLive/Info.plist; INFOPLIST_FILE = SessionStatusLive/Info.plist;
@@ -522,7 +633,7 @@
"@executable_path/Frameworks", "@executable_path/Frameworks",
"@executable_path/../../Frameworks", "@executable_path/../../Frameworks",
); );
MARKETING_VERSION = 1.2.1; MARKETING_VERSION = 1.2.2;
PRODUCT_BUNDLE_IDENTIFIER = com.atridad.OpenClimb.SessionStatusLive; PRODUCT_BUNDLE_IDENTIFIER = com.atridad.OpenClimb.SessionStatusLive;
PRODUCT_NAME = "$(TARGET_NAME)"; PRODUCT_NAME = "$(TARGET_NAME)";
SKIP_INSTALL = YES; SKIP_INSTALL = YES;
@@ -555,6 +666,15 @@
defaultConfigurationIsVisible = 0; defaultConfigurationIsVisible = 0;
defaultConfigurationName = Release; 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" */ = { D2FE94A12E78FEE1008CDB25 /* Build configuration list for PBXNativeTarget "SessionStatusLiveExtension" */ = {
isa = XCConfigurationList; isa = XCConfigurationList;
buildConfigurations = ( buildConfigurations = (

View File

@@ -28,6 +28,17 @@
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB" selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
shouldUseLaunchSchemeArgsEnv = "YES"> shouldUseLaunchSchemeArgsEnv = "YES">
<Testables> <Testables>
<TestableReference
skipped = "NO"
parallelizable = "YES">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "D2F32FAC2E90B26500B1BC56"
BuildableName = "OpenClimbTests.xctest"
BlueprintName = "OpenClimbTests"
ReferencedContainer = "container:OpenClimb.xcodeproj">
</BuildableReference>
</TestableReference>
</Testables> </Testables>
</TestAction> </TestAction>
<LaunchAction <LaunchAction

View File

@@ -44,6 +44,19 @@
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB" selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
shouldUseLaunchSchemeArgsEnv = "YES" shouldUseLaunchSchemeArgsEnv = "YES"
shouldAutocreateTestPlan = "YES"> shouldAutocreateTestPlan = "YES">
<Testables>
<TestableReference
skipped = "NO"
parallelizable = "YES">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "D2F32FAC2E90B26500B1BC56"
BuildableName = "OpenClimbTests.xctest"
BlueprintName = "OpenClimbTests"
ReferencedContainer = "container:OpenClimb.xcodeproj">
</BuildableReference>
</TestableReference>
</Testables>
</TestAction> </TestAction>
<LaunchAction <LaunchAction
buildConfiguration = "Debug" buildConfiguration = "Debug"

View File

@@ -85,7 +85,7 @@ struct ContentView: View {
object: nil, object: nil,
queue: .main queue: .main
) { _ in ) { _ in
print("📱 App will enter foreground - preparing Live Activity check") print("App will enter foreground - preparing Live Activity check")
Task { Task {
// Small delay to ensure app is fully active // Small delay to ensure app is fully active
try? await Task.sleep(nanoseconds: 800_000_000) // 0.8 seconds try? await Task.sleep(nanoseconds: 800_000_000) // 0.8 seconds
@@ -99,7 +99,7 @@ struct ContentView: View {
object: nil, object: nil,
queue: .main queue: .main
) { _ in ) { _ in
print("📱 App did become active - checking Live Activity status") print("App did become active - checking Live Activity status")
Task { Task {
try? await Task.sleep(nanoseconds: 300_000_000) // 0.3 seconds try? await Task.sleep(nanoseconds: 300_000_000) // 0.3 seconds
await dataManager.onAppBecomeActive() await dataManager.onAppBecomeActive()

View File

@@ -4,9 +4,6 @@
import Foundation import Foundation
// MARK: - Backup Format Specification v2.0 // MARK: - Backup Format Specification v2.0
// Platform-neutral backup format for cross-platform compatibility
// This format ensures portability between iOS and Android while maintaining
// platform-specific implementations
/// Root structure for OpenClimb backup data /// Root structure for OpenClimb backup data
struct ClimbDataBackup: Codable { struct ClimbDataBackup: Codable {
@@ -37,7 +34,7 @@ struct ClimbDataBackup: Codable {
} }
} }
/// Platform-neutral gym representation for backup/restore // Platform-neutral gym representation for backup/restore
struct BackupGym: Codable { struct BackupGym: Codable {
let id: String let id: String
let name: String let name: String
@@ -46,8 +43,8 @@ struct BackupGym: Codable {
let difficultySystems: [DifficultySystem] let difficultySystems: [DifficultySystem]
let customDifficultyGrades: [String] let customDifficultyGrades: [String]
let notes: String? let notes: String?
let createdAt: String // ISO 8601 format let createdAt: String
let updatedAt: String // ISO 8601 format let updatedAt: String
/// Initialize from native iOS Gym model /// Initialize from native iOS Gym model
init(from gym: Gym) { init(from gym: Gym) {
@@ -114,7 +111,7 @@ struct BackupGym: Codable {
} }
} }
/// Platform-neutral problem representation for backup/restore // Platform-neutral problem representation for backup/restore
struct BackupProblem: Codable { struct BackupProblem: Codable {
let id: String let id: String
let gymId: String let gymId: String
@@ -128,8 +125,8 @@ struct BackupProblem: Codable {
let isActive: Bool let isActive: Bool
let dateSet: String? // ISO 8601 format let dateSet: String? // ISO 8601 format
let notes: String? let notes: String?
let createdAt: String // ISO 8601 format let createdAt: String
let updatedAt: String // ISO 8601 format let updatedAt: String
/// Initialize from native iOS Problem model /// Initialize from native iOS Problem model
init(from problem: Problem) { init(from problem: Problem) {
@@ -239,7 +236,7 @@ struct BackupProblem: Codable {
} }
} }
/// Platform-neutral climb session representation for backup/restore // Platform-neutral climb session representation for backup/restore
struct BackupClimbSession: Codable { struct BackupClimbSession: Codable {
let id: String let id: String
let gymId: String let gymId: String
@@ -249,8 +246,8 @@ struct BackupClimbSession: Codable {
let duration: Int64? // Duration in seconds let duration: Int64? // Duration in seconds
let status: SessionStatus let status: SessionStatus
let notes: String? let notes: String?
let createdAt: String // ISO 8601 format let createdAt: String
let updatedAt: String // ISO 8601 format let updatedAt: String
/// Initialize from native iOS ClimbSession model /// Initialize from native iOS ClimbSession model
init(from session: ClimbSession) { init(from session: ClimbSession) {
@@ -327,7 +324,7 @@ struct BackupClimbSession: Codable {
} }
} }
/// Platform-neutral attempt representation for backup/restore // Platform-neutral attempt representation for backup/restore
struct BackupAttempt: Codable { struct BackupAttempt: Codable {
let id: String let id: String
let sessionId: String let sessionId: String
@@ -337,8 +334,8 @@ struct BackupAttempt: Codable {
let notes: String? let notes: String?
let duration: Int64? // Duration in seconds let duration: Int64? // Duration in seconds
let restTime: Int64? // Rest time in seconds let restTime: Int64? // Rest time in seconds
let timestamp: String // ISO 8601 format let timestamp: String
let createdAt: String // ISO 8601 format let createdAt: String
/// Initialize from native iOS Attempt model /// Initialize from native iOS Attempt model
init(from attempt: Attempt) { init(from attempt: Attempt) {

View File

@@ -230,7 +230,7 @@ class SyncService: ObservableObject {
if !hasLocalData && hasServerData { if !hasLocalData && hasServerData {
// Case 1: No local data - do full restore from server // Case 1: No local data - do full restore from server
print("🔄 iOS SYNC: Case 1 - No local data, performing full restore from server") print("iOS SYNC: Case 1 - No local data, performing full restore from server")
print("Syncing images from server first...") print("Syncing images from server first...")
let imagePathMapping = try await syncImagesFromServer( let imagePathMapping = try await syncImagesFromServer(
backup: serverBackup, dataManager: dataManager) backup: serverBackup, dataManager: dataManager)
@@ -240,7 +240,7 @@ class SyncService: ObservableObject {
print("Full restore completed") print("Full restore completed")
} else if hasLocalData && !hasServerData { } else if hasLocalData && !hasServerData {
// Case 2: No server data - upload local data to server // Case 2: No server data - upload local data to server
print("🔄 iOS SYNC: Case 2 - No server data, uploading local data to server") print("iOS SYNC: Case 2 - No server data, uploading local data to server")
let currentBackup = createBackupFromDataManager(dataManager) let currentBackup = createBackupFromDataManager(dataManager)
_ = try await uploadData(currentBackup) _ = try await uploadData(currentBackup)
print("Uploading local images to server...") print("Uploading local images to server...")
@@ -251,7 +251,7 @@ class SyncService: ObservableObject {
let localTimestamp = parseISO8601ToMillis(timestamp: localBackup.exportedAt) let localTimestamp = parseISO8601ToMillis(timestamp: localBackup.exportedAt)
let serverTimestamp = parseISO8601ToMillis(timestamp: serverBackup.exportedAt) let serverTimestamp = parseISO8601ToMillis(timestamp: serverBackup.exportedAt)
print("🕐 DEBUG iOS Timestamp Comparison:") print("DEBUG iOS Timestamp Comparison:")
print(" Local exportedAt: '\(localBackup.exportedAt)' -> \(localTimestamp)") print(" Local exportedAt: '\(localBackup.exportedAt)' -> \(localTimestamp)")
print(" Server exportedAt: '\(serverBackup.exportedAt)' -> \(serverTimestamp)") print(" Server exportedAt: '\(serverBackup.exportedAt)' -> \(serverTimestamp)")
print( print(
@@ -261,14 +261,14 @@ class SyncService: ObservableObject {
if localTimestamp > serverTimestamp { if localTimestamp > serverTimestamp {
// Local is newer - replace server with local data // 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) let currentBackup = createBackupFromDataManager(dataManager)
_ = try await uploadData(currentBackup) _ = try await uploadData(currentBackup)
try await syncImagesToServer(dataManager: dataManager) try await syncImagesToServer(dataManager: dataManager)
print("Server replaced with local data") print("Server replaced with local data")
} else if serverTimestamp > localTimestamp { } else if serverTimestamp > localTimestamp {
// Server is newer - replace local with server data // 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( let imagePathMapping = try await syncImagesFromServer(
backup: serverBackup, dataManager: dataManager) backup: serverBackup, dataManager: dataManager)
try importBackupToDataManager( try importBackupToDataManager(
@@ -277,7 +277,7 @@ class SyncService: ObservableObject {
} else { } else {
// Timestamps are equal - no sync needed // Timestamps are equal - no sync needed
print( 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 { } else {

View File

@@ -3,8 +3,7 @@
import Foundation import Foundation
/// Manages the overall data state timestamp for sync purposes. This tracks when any data in the /// Manages the overall data state timestamp for sync purposes
/// local database was last modified, independent of individual entity timestamps.
class DataStateManager { class DataStateManager {
private let userDefaults = UserDefaults.standard private let userDefaults = UserDefaults.standard
@@ -14,7 +13,6 @@ class DataStateManager {
static let initialized = "openclimb_data_state_initialized" static let initialized = "openclimb_data_state_initialized"
} }
/// Shared instance for app-wide use
static let shared = DataStateManager() static let shared = DataStateManager()
private init() { private init() {
@@ -36,21 +34,21 @@ class DataStateManager {
func updateDataState() { func updateDataState() {
let now = ISO8601DateFormatter().string(from: Date()) let now = ISO8601DateFormatter().string(from: Date())
userDefaults.set(now, forKey: Keys.lastModified) 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 /// Gets the current data state timestamp. This represents when any data was last modified
/// locally. /// locally.
func getLastModified() -> String { func getLastModified() -> String {
if let storedTimestamp = userDefaults.string(forKey: Keys.lastModified) { if let storedTimestamp = userDefaults.string(forKey: Keys.lastModified) {
print("📅 iOS DataStateManager returning stored timestamp: \(storedTimestamp)") print("iOS DataStateManager returning stored timestamp: \(storedTimestamp)")
return storedTimestamp return storedTimestamp
} }
// If no timestamp is stored, return epoch time to indicate very old data // 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 // This ensures server data will be considered newer than uninitialized local data
let epochTime = "1970-01-01T00:00:00.000Z" 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 return epochTime
} }

View File

@@ -1,4 +1,3 @@
import Combine import Combine
import SwiftUI import SwiftUI
@@ -11,7 +10,7 @@ import SwiftUI
@State private var testResults: [String] = [] @State private var testResults: [String] = []
var body: some View { var body: some View {
NavigationView { NavigationStack {
List { List {
StatusSection() StatusSection()
@@ -263,10 +262,10 @@ import SwiftUI
ForEach(testResults.indices, id: \.self) { index in ForEach(testResults.indices, id: \.self) { index in
HStack { HStack {
Image( Image(
systemName: testResults[index].contains("") systemName: testResults[index].contains("PASS")
? "checkmark.circle.fill" : "exclamationmark.triangle.fill" ? "checkmark.circle.fill" : "exclamationmark.triangle.fill"
) )
.foregroundColor(testResults[index].contains("") ? .green : .orange) .foregroundColor(testResults[index].contains("PASS") ? .green : .orange)
Text(testResults[index]) Text(testResults[index])
.font(.caption) .font(.caption)
@@ -286,24 +285,24 @@ import SwiftUI
// Test 1: Check iOS version compatibility // Test 1: Check iOS version compatibility
if iconHelper.supportsModernIconFeatures { if iconHelper.supportsModernIconFeatures {
testResults.append(" iOS 17+ features supported") testResults.append("PASS: iOS 17+ features supported")
} else { } else {
testResults.append( 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 // Test 2: Check dark mode detection
let detectedDarkMode = iconHelper.isInDarkMode(for: colorScheme) let detectedDarkMode = iconHelper.isInDarkMode(for: colorScheme)
let systemDarkMode = colorScheme == .dark let systemDarkMode = colorScheme == .dark
if detectedDarkMode == systemDarkMode { if detectedDarkMode == systemDarkMode {
testResults.append(" Dark mode detection matches system setting") testResults.append("PASS: Dark mode detection matches system setting")
} else { } else {
testResults.append("⚠️ Dark mode detection mismatch") testResults.append("WARNING: Dark mode detection mismatch")
} }
// Test 3: Check recommended variant // Test 3: Check recommended variant
let variant = iconHelper.getRecommendedIconVariant(for: colorScheme) 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 // Test 4: Test asset availability
validateAssetConfiguration() validateAssetConfiguration()
@@ -316,7 +315,7 @@ import SwiftUI
iconHelper.updateDarkModeStatus(for: colorScheme) iconHelper.updateDarkModeStatus(for: colorScheme)
let variant = iconHelper.getRecommendedIconVariant(for: colorScheme) let variant = iconHelper.getRecommendedIconVariant(for: colorScheme)
testResults.append( testResults.append(
" Icon appearance test completed - Current variant: \(variant.description)") "PASS: Icon appearance test completed - Current variant: \(variant.description)")
} }
private func validateAssetConfiguration() { private func validateAssetConfiguration() {
@@ -327,20 +326,20 @@ import SwiftUI
] ]
for asset in expectedAssets { for asset in expectedAssets {
testResults.append(" Asset '\(asset)' configuration found") testResults.append("PASS: Asset '\(asset)' configuration found")
} }
} }
private func checkBundleResources() { private func checkBundleResources() {
// Check bundle identifier // Check bundle identifier
let bundleId = Bundle.main.bundleIdentifier ?? "Unknown" let bundleId = Bundle.main.bundleIdentifier ?? "Unknown"
testResults.append(" Bundle ID: \(bundleId)") testResults.append("PASS: Bundle ID: \(bundleId)")
// Check app version // Check app version
let version = let version =
Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "Unknown" Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "Unknown"
let build = Bundle.main.infoDictionary?["CFBundleVersion"] 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 @Environment(\.colorScheme) private var colorScheme
var body: some View { var body: some View {
NavigationView { NavigationStack {
VStack(spacing: 30) { VStack(spacing: 30) {
Text("Icon Appearance Comparison") Text("Icon Appearance Comparison")
.font(.title2) .font(.title2)

View File

@@ -23,7 +23,7 @@ class ImageManager {
// Final integrity check // Final integrity check
if !validateStorageIntegrity() { if !validateStorageIntegrity() {
print("🚨 CRITICAL: Storage integrity compromised - attempting emergency recovery") print("CRITICAL: Storage integrity compromised - attempting emergency recovery")
emergencyImageRestore() emergencyImageRestore()
} }
@@ -69,9 +69,9 @@ class ImageManager {
attributes: [ attributes: [
.protectionKey: FileProtectionType.completeUntilFirstUserAuthentication .protectionKey: FileProtectionType.completeUntilFirstUserAuthentication
]) ])
print("Created directory: \(directory.path)") print("Created directory: \(directory.path)")
} catch { } 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 var backupURL = backupDirectory
try imagesURL.setResourceValues(resourceValues) try imagesURL.setResourceValues(resourceValues)
try backupURL.setResourceValues(resourceValues) try backupURL.setResourceValues(resourceValues)
print("Excluded image directories from iCloud backup") print("Excluded image directories from iCloud backup")
} catch { } 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() { private func performRobustMigration() {
print("🔄 Starting robust image migration system...") print("Starting robust image migration system...")
// Check for interrupted migration // Check for interrupted migration
if let incompleteState = loadMigrationState() { if let incompleteState = loadMigrationState() {
print("🔧 Detected interrupted migration, resuming...") print("Detected interrupted migration, resuming...")
resumeMigration(from: incompleteState) resumeMigration(from: incompleteState)
} else { } else {
// Start fresh migration // Start fresh migration
@@ -135,7 +135,7 @@ class ImageManager {
private func startNewMigration() { private func startNewMigration() {
// First check for images in previous Application Support directories // First check for images in previous Application Support directories
if let previousAppSupportImages = findPreviousAppSupportImages() { if let previousAppSupportImages = findPreviousAppSupportImages() {
print("📁 Found images in previous Application Support directory") print("Found images in previous Application Support directory")
migratePreviousAppSupportImages(from: previousAppSupportImages) migratePreviousAppSupportImages(from: previousAppSupportImages)
return return
} }
@@ -145,7 +145,7 @@ class ImageManager {
let hasLegacyImportImages = fileManager.fileExists(atPath: legacyImportImagesDirectory.path) let hasLegacyImportImages = fileManager.fileExists(atPath: legacyImportImagesDirectory.path)
guard hasLegacyImages || hasLegacyImportImages else { guard hasLegacyImages || hasLegacyImportImages else {
print("No legacy images to migrate") print("No legacy images to migrate")
return return
} }
@@ -160,7 +160,7 @@ class ImageManager {
let legacyFiles = try fileManager.contentsOfDirectory( let legacyFiles = try fileManager.contentsOfDirectory(
atPath: legacyImagesDirectory.path) atPath: legacyImagesDirectory.path)
allLegacyFiles.append(contentsOf: legacyFiles) allLegacyFiles.append(contentsOf: legacyFiles)
print("📦 Found \(legacyFiles.count) images in OpenClimbImages") print("Found \(legacyFiles.count) images in OpenClimbImages")
} }
// Collect files from Documents/images directory // Collect files from Documents/images directory
@@ -168,10 +168,10 @@ class ImageManager {
let importFiles = try fileManager.contentsOfDirectory( let importFiles = try fileManager.contentsOfDirectory(
atPath: legacyImportImagesDirectory.path) atPath: legacyImportImagesDirectory.path)
allLegacyFiles.append(contentsOf: importFiles) 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( let initialState = MigrationState(
version: MigrationState.currentVersion, version: MigrationState.currentVersion,
@@ -186,24 +186,24 @@ class ImageManager {
performMigrationWithCheckpoints(files: allLegacyFiles, currentState: initialState) performMigrationWithCheckpoints(files: allLegacyFiles, currentState: initialState)
} catch { } catch {
print(" Failed to start migration: \(error)") print("ERROR: Failed to start migration: \(error)")
} }
} }
private func resumeMigration(from state: MigrationState) { private func resumeMigration(from state: MigrationState) {
print("🔄 Resuming migration from checkpoint...") print("Resuming migration from checkpoint...")
print("📊 Progress: \(state.completedFiles.count)/\(state.totalFiles)") print("Progress: \(state.completedFiles.count)/\(state.totalFiles)")
do { do {
let legacyFiles = try fileManager.contentsOfDirectory( let legacyFiles = try fileManager.contentsOfDirectory(
atPath: legacyImagesDirectory.path) atPath: legacyImagesDirectory.path)
let remainingFiles = legacyFiles.filter { !state.completedFiles.contains($0) } 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) performMigrationWithCheckpoints(files: remainingFiles, currentState: state)
} catch { } catch {
print(" Failed to resume migration: \(error)") print("ERROR: Failed to resume migration: \(error)")
// Fallback: start fresh // Fallback: start fresh
removeMigrationState() removeMigrationState()
startNewMigration() startNewMigration()
@@ -270,11 +270,11 @@ class ImageManager {
completedFiles.append(fileName) completedFiles.append(fileName)
migratedCount += 1 migratedCount += 1
print("Migrated: \(fileName) (\(migratedCount)/\(currentState.totalFiles))") print("Migrated: \(fileName) (\(migratedCount)/\(currentState.totalFiles))")
} catch { } catch {
failedCount += 1 failedCount += 1
print(" Failed to migrate \(fileName): \(error)") print("ERROR: Failed to migrate \(fileName): \(error)")
} }
// Save checkpoint every 5 files or if interrupted // Save checkpoint every 5 files or if interrupted
@@ -288,7 +288,7 @@ class ImageManager {
lastCheckpoint: Date() lastCheckpoint: Date()
) )
saveMigrationState(checkpointState) saveMigrationState(checkpointState)
print("💾 Checkpoint saved: \(completedFiles.count)/\(currentState.totalFiles)") print("Checkpoint saved: \(completedFiles.count)/\(currentState.totalFiles)")
} }
} }
} }
@@ -304,7 +304,7 @@ class ImageManager {
) )
saveMigrationState(finalState) saveMigrationState(finalState)
print("🏁 Migration complete: \(migratedCount) migrated, \(failedCount) failed") print("Migration complete: \(migratedCount) migrated, \(failedCount) failed")
// Clean up legacy directory if no failures // Clean up legacy directory if no failures
if failedCount == 0 { if failedCount == 0 {
@@ -313,7 +313,7 @@ class ImageManager {
} }
private func verifyMigrationIntegrity() { private func verifyMigrationIntegrity() {
print("🔍 Verifying migration integrity...") print("Verifying migration integrity...")
var allLegacyFiles = Set<String>() var allLegacyFiles = Set<String>()
@@ -331,12 +331,12 @@ class ImageManager {
allLegacyFiles.formUnion(importFiles) allLegacyFiles.formUnion(importFiles)
} }
} catch { } catch {
print(" Failed to read legacy directories: \(error)") print("ERROR: Failed to read legacy directories: \(error)")
return return
} }
guard !allLegacyFiles.isEmpty else { guard !allLegacyFiles.isEmpty else {
print("No legacy directories to verify against") print("No legacy directories to verify against")
return return
} }
@@ -347,10 +347,10 @@ class ImageManager {
let missingFiles = allLegacyFiles.subtracting(migratedFiles) let missingFiles = allLegacyFiles.subtracting(migratedFiles)
if missingFiles.isEmpty { if missingFiles.isEmpty {
print("Migration integrity verified - all files present") print("Migration integrity verified - all files present")
cleanupLegacyDirectory() cleanupLegacyDirectory()
} else { } else {
print("⚠️ Missing \(missingFiles.count) files, re-triggering migration") print("WARNING: Missing \(missingFiles.count) files, re-triggering migration")
// Re-trigger migration for missing files // Re-trigger migration for missing files
performMigrationWithCheckpoints( performMigrationWithCheckpoints(
files: Array(missingFiles), files: Array(missingFiles),
@@ -364,16 +364,16 @@ class ImageManager {
)) ))
} }
} catch { } catch {
print(" Failed to verify migration integrity: \(error)") print("ERROR: Failed to verify migration integrity: \(error)")
} }
} }
private func cleanupLegacyDirectory() { private func cleanupLegacyDirectory() {
do { do {
try fileManager.removeItem(at: legacyImagesDirectory) try fileManager.removeItem(at: legacyImagesDirectory)
print("🗑️ Cleaned up legacy directory") print("Cleaned up legacy directory")
} catch { } 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) // Check if state is too old (more than 1 hour)
if Date().timeIntervalSince(state.lastCheckpoint) > 3600 { if Date().timeIntervalSince(state.lastCheckpoint) > 3600 {
print("⚠️ Migration state is stale, starting fresh") print("WARNING: Migration state is stale, starting fresh")
removeMigrationState() removeMigrationState()
return nil return nil
} }
return state.isComplete ? nil : state return state.isComplete ? nil : state
} catch { } catch {
print(" Failed to load migration state: \(error)") print("ERROR: Failed to load migration state: \(error)")
removeMigrationState() removeMigrationState()
return nil return nil
} }
@@ -413,7 +413,7 @@ class ImageManager {
let data = try JSONEncoder().encode(state) let data = try JSONEncoder().encode(state)
try data.write(to: migrationStateURL) try data.write(to: migrationStateURL)
} catch { } 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() { private func cleanupMigrationState() {
try? fileManager.removeItem(at: migrationStateURL) try? fileManager.removeItem(at: migrationStateURL)
try? fileManager.removeItem(at: migrationLockURL) 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? { func saveImageData(_ data: Data, withName name: String? = nil) -> String? {
@@ -444,10 +444,10 @@ class ImageManager {
// Create backup copy // Create backup copy
try data.write(to: backupPath) try data.write(to: backupPath)
print("Saved image with backup: \(fileName)") print("Saved image with backup: \(fileName)")
return fileName return fileName
} catch { } catch {
print(" Failed to save image \(fileName): \(error)") print("ERROR: Failed to save image \(fileName): \(error)")
return nil return nil
} }
} }
@@ -467,7 +467,7 @@ class ImageManager {
if fileManager.fileExists(atPath: backupPath.path), if fileManager.fileExists(atPath: backupPath.path),
let data = try? Data(contentsOf: backupPath) let data = try? Data(contentsOf: backupPath)
{ {
print("📦 Restored image from backup: \(path)") print("Restored image from backup: \(path)")
// Restore to primary location // Restore to primary location
try? data.write(to: URL(fileURLWithPath: primaryPath)) try? data.write(to: URL(fileURLWithPath: primaryPath))
@@ -497,7 +497,7 @@ class ImageManager {
do { do {
try fileManager.removeItem(atPath: primaryPath) try fileManager.removeItem(atPath: primaryPath)
} catch { } catch {
print(" Failed to delete primary image at \(primaryPath): \(error)") print("ERROR: Failed to delete primary image at \(primaryPath): \(error)")
success = false success = false
} }
} }
@@ -507,7 +507,7 @@ class ImageManager {
do { do {
try fileManager.removeItem(at: backupPath) try fileManager.removeItem(at: backupPath)
} catch { } catch {
print(" Failed to delete backup image at \(backupPath.path): \(error)") print("ERROR: Failed to delete backup image at \(backupPath.path): \(error)")
success = false success = false
} }
} }
@@ -544,7 +544,7 @@ class ImageManager {
} }
func performMaintenance() { func performMaintenance() {
print("🔧 Starting image maintenance...") print("Starting image maintenance...")
syncBackups() syncBackups()
validateImageIntegrity() validateImageIntegrity()
@@ -562,11 +562,11 @@ class ImageManager {
let backupPath = backupDirectory.appendingPathComponent(fileName) let backupPath = backupDirectory.appendingPathComponent(fileName)
try? fileManager.copyItem(at: primaryPath, to: backupPath) try? fileManager.copyItem(at: primaryPath, to: backupPath)
print("🔄 Created missing backup for: \(fileName)") print("Created missing backup for: \(fileName)")
} }
} }
} catch { } 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 { } catch {
print(" Failed to validate images: \(error)") print("ERROR: Failed to validate images: \(error)")
} }
} }
private func cleanupOrphanedFiles() { private func cleanupOrphanedFiles() {
// This would need access to the data manager to check which files are actually referenced // 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) { func getStorageInfo() -> (primaryCount: Int, backupCount: Int, totalSize: Int64) {
@@ -623,7 +623,7 @@ class ImageManager {
let previousDir = findPreviousAppSupportImages() let previousDir = findPreviousAppSupportImages()
print( print(
""" """
📁 OpenClimb Image Storage: OpenClimb Image Storage:
- App Support: \(appSupportDirectory.path) - App Support: \(appSupportDirectory.path)
- Images: \(imagesDirectory.path) (\(info.primaryCount) files) - Images: \(imagesDirectory.path) (\(info.primaryCount) files)
- Backups: \(backupDirectory.path) (\(info.backupCount) files) - Backups: \(backupDirectory.path) (\(info.backupCount) files)
@@ -635,7 +635,7 @@ class ImageManager {
} }
func forceRecoveryMigration() { func forceRecoveryMigration() {
print("🚨 FORCE RECOVERY: Starting manual migration recovery...") print("FORCE RECOVERY: Starting manual migration recovery...")
// Remove any stale state // Remove any stale state
removeMigrationState() removeMigrationState()
@@ -644,7 +644,7 @@ class ImageManager {
// Force fresh migration // Force fresh migration
startNewMigration() startNewMigration()
print("🚨 FORCE RECOVERY: Migration recovery completed") print("FORCE RECOVERY: Migration recovery completed")
} }
func saveImportedImage(_ imageData: Data, filename: String) throws -> String { func saveImportedImage(_ imageData: Data, filename: String) throws -> String {
@@ -657,12 +657,12 @@ class ImageManager {
// Create backup // Create backup
try? imageData.write(to: backupPath) try? imageData.write(to: backupPath)
print("📥 Imported image: \(filename)") print("Imported image: \(filename)")
return filename return filename
} }
func emergencyImageRestore() { func emergencyImageRestore() {
print("🆘 EMERGENCY: Attempting image restoration...") print("EMERGENCY: Attempting image restoration...")
// Try to restore from backup directory // Try to restore from backup directory
do { do {
@@ -680,14 +680,14 @@ class ImageManager {
} }
} }
print("🆘 EMERGENCY: Restored \(restoredCount) images from backup") print("EMERGENCY: Restored \(restoredCount) images from backup")
} catch { } catch {
print("🆘 EMERGENCY: Failed to restore from backup: \(error)") print("EMERGENCY: Failed to restore from backup: \(error)")
} }
// Try previous Application Support directories first // Try previous Application Support directories first
if let previousAppSupportImages = findPreviousAppSupportImages() { if let previousAppSupportImages = findPreviousAppSupportImages() {
print("🆘 EMERGENCY: Found previous Application Support images, migrating...") print("EMERGENCY: Found previous Application Support images, migrating...")
migratePreviousAppSupportImages(from: previousAppSupportImages) migratePreviousAppSupportImages(from: previousAppSupportImages)
return return
} }
@@ -696,21 +696,21 @@ class ImageManager {
if fileManager.fileExists(atPath: legacyImagesDirectory.path) if fileManager.fileExists(atPath: legacyImagesDirectory.path)
|| fileManager.fileExists(atPath: legacyImportImagesDirectory.path) || fileManager.fileExists(atPath: legacyImportImagesDirectory.path)
{ {
print("🆘 EMERGENCY: Attempting legacy migration as fallback...") print("EMERGENCY: Attempting legacy migration as fallback...")
forceRecoveryMigration() forceRecoveryMigration()
} }
} }
func debugSafeInitialization() -> Bool { 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 // Check if we're in a debug environment
#if DEBUG #if DEBUG
print("🐛 DEBUG SAFE: Debug environment detected") print("DEBUG SAFE: Debug environment detected")
// Check for interrupted migration more aggressively // Check for interrupted migration more aggressively
if fileManager.fileExists(atPath: migrationLockURL.path) { 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 // Give extra time for file system to stabilize
Thread.sleep(forTimeInterval: 1.0) Thread.sleep(forTimeInterval: 1.0)
@@ -732,14 +732,14 @@ class ImageManager {
((try? fileManager.contentsOfDirectory(atPath: backupDirectory.path)) ?? []).count > 0 ((try? fileManager.contentsOfDirectory(atPath: backupDirectory.path)) ?? []).count > 0
if primaryEmpty && backupHasFiles { if primaryEmpty && backupHasFiles {
print("🐛 DEBUG SAFE: Primary empty but backup exists - restoring") print("DEBUG SAFE: Primary empty but backup exists - restoring")
emergencyImageRestore() emergencyImageRestore()
return true return true
} }
// Check if primary storage is empty but previous Application Support images exist // Check if primary storage is empty but previous Application Support images exist
if primaryEmpty, let previousAppSupportImages = findPreviousAppSupportImages() { 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) migratePreviousAppSupportImages(from: previousAppSupportImages)
return true return true
} }
@@ -755,13 +755,15 @@ class ImageManager {
// Check if we have more backups than primary files (sign of corruption) // Check if we have more backups than primary files (sign of corruption)
if backupFiles.count > primaryFiles.count + 5 { 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 return false
} }
// Check if primary is completely empty but we have data elsewhere // Check if primary is completely empty but we have data elsewhere
if primaryFiles.isEmpty && !backupFiles.isEmpty { if primaryFiles.isEmpty && !backupFiles.isEmpty {
print("⚠️ INTEGRITY: Primary storage empty but backups exist") print("WARNING INTEGRITY: Primary storage empty but backups exist")
return false return false
} }
@@ -775,7 +777,7 @@ class ImageManager {
for: .applicationSupportDirectory, in: .userDomainMask for: .applicationSupportDirectory, in: .userDomainMask
).first ).first
else { else {
print(" Could not access Application Support directory") print("ERROR: Could not access Application Support directory")
return nil return nil
} }
@@ -808,13 +810,13 @@ class ImageManager {
} }
} }
} catch { } catch {
print(" Error scanning for previous Application Support directories: \(error)") print("ERROR: Error scanning for previous Application Support directories: \(error)")
} }
return nil return nil
} }
private func migratePreviousAppSupportImages(from sourceDirectory: URL) { private func migratePreviousAppSupportImages(from sourceDirectory: URL) {
print("🔄 Migrating images from previous Application Support directory") print("Migrating images from previous Application Support directory")
do { do {
let imageFiles = try fileManager.contentsOfDirectory(atPath: sourceDirectory.path) let imageFiles = try fileManager.contentsOfDirectory(atPath: sourceDirectory.path)
@@ -837,17 +839,17 @@ class ImageManager {
// Create backup // Create backup
try? fileManager.copyItem(at: sourcePath, to: backupPath) try? fileManager.copyItem(at: sourcePath, to: backupPath)
print("Migrated: \(fileName)") print("Migrated: \(fileName)")
} catch { } 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 { } catch {
print(" Failed to migrate from previous Application Support: \(error)") print("ERROR: Failed to migrate from previous Application Support: \(error)")
} }
} }
} }

View File

@@ -4,54 +4,36 @@
import CryptoKit import CryptoKit
import Foundation import Foundation
/// Utility for creating consistent image filenames across iOS and Android platforms. /// Utility for creating consistent image filenames across platforms
/// Uses deterministic naming based on problem ID and timestamp to ensure sync compatibility.
class ImageNamingUtils { class ImageNamingUtils {
private static let imageExtension = ".jpg" 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. /// 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
static func generateImageFilename(problemId: String, timestamp: String, imageIndex: Int) static func generateImageFilename(problemId: String, timestamp: String, imageIndex: Int)
-> String -> String
{ {
// Create a deterministic hash from problemId + timestamp + index
let input = "\(problemId)_\(timestamp)_\(imageIndex)" let input = "\(problemId)_\(timestamp)_\(imageIndex)"
let hash = createHash(from: input) let hash = createHash(from: input)
return "problem_\(hash)_\(imageIndex)\(imageExtension)" return "problem_\(hash)_\(imageIndex)\(imageExtension)"
} }
/// Generates a deterministic filename for a problem image using current timestamp. /// Generates a deterministic filename 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
static func generateImageFilename(problemId: String, imageIndex: Int) -> String { static func generateImageFilename(problemId: String, imageIndex: Int) -> String {
let timestamp = ISO8601DateFormatter().string(from: Date()) let timestamp = ISO8601DateFormatter().string(from: Date())
return generateImageFilename( return generateImageFilename(
problemId: problemId, timestamp: timestamp, imageIndex: imageIndex) problemId: problemId, timestamp: timestamp, imageIndex: imageIndex)
} }
/// Extracts problem ID from an image filename created by this utility. /// Extracts problem ID from an image filename
/// 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
static func extractProblemIdFromFilename(_ filename: String) -> String? { static func extractProblemIdFromFilename(_ filename: String) -> String? {
guard filename.hasPrefix("problem_") && filename.hasSuffix(imageExtension) else { guard filename.hasPrefix("problem_") && filename.hasSuffix(imageExtension) else {
return nil return nil
} }
// Format: problem_{hash}_{index}.jpg
let nameWithoutExtension = String(filename.dropLast(imageExtension.count)) let nameWithoutExtension = String(filename.dropLast(imageExtension.count))
let parts = nameWithoutExtension.components(separatedBy: "_") let parts = nameWithoutExtension.components(separatedBy: "_")
@@ -59,14 +41,10 @@ class ImageNamingUtils {
return nil return nil
} }
// Return the hash as identifier
return parts[1] return parts[1]
} }
/// Validates if a filename follows our naming convention. /// Validates if a filename follows our naming convention
///
/// - Parameter filename: The filename to validate
/// - Returns: true if it matches our convention, false otherwise
static func isValidImageFilename(_ filename: String) -> Bool { static func isValidImageFilename(_ filename: String) -> Bool {
guard filename.hasPrefix("problem_") && filename.hasSuffix(imageExtension) else { guard filename.hasPrefix("problem_") && filename.hasSuffix(imageExtension) else {
return false return false
@@ -79,32 +57,19 @@ class ImageNamingUtils {
&& Int(parts[2]) != nil && Int(parts[2]) != nil
} }
/// Migrates an existing UUID-based filename to our naming convention. /// Migrates an existing 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
static func migrateFilename(oldFilename: String, problemId: String, imageIndex: Int) -> String { static func migrateFilename(oldFilename: String, problemId: String, imageIndex: Int) -> String {
// If it's already using our convention, keep it
if isValidImageFilename(oldFilename) { if isValidImageFilename(oldFilename) {
return oldFilename return oldFilename
} }
// Generate new deterministic name
// Use current timestamp to maintain some consistency
let timestamp = ISO8601DateFormatter().string(from: Date()) let timestamp = ISO8601DateFormatter().string(from: Date())
return generateImageFilename( return generateImageFilename(
problemId: problemId, timestamp: timestamp, imageIndex: imageIndex) problemId: problemId, timestamp: timestamp, imageIndex: imageIndex)
} }
/// Creates a deterministic hash from input string. /// 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
private static func createHash(from input: String) -> String { private static func createHash(from input: String) -> String {
let inputData = Data(input.utf8) let inputData = Data(input.utf8)
let hashed = SHA256.hash(data: inputData) let hashed = SHA256.hash(data: inputData)
@@ -112,13 +77,7 @@ class ImageNamingUtils {
return String(hashString.prefix(hashLength)) return String(hashString.prefix(hashLength))
} }
/// Batch renames images for a problem to use our naming convention. /// 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
static func batchRenameForProblem(problemId: String, existingFilenames: [String]) -> [String: static func batchRenameForProblem(problemId: String, existingFilenames: [String]) -> [String:
String] String]
{ {
@@ -135,10 +94,7 @@ class ImageNamingUtils {
return renameMap return renameMap
} }
/// Validates that a collection of filenames follow our naming convention. /// Validates that a collection of filenames follow our naming convention
///
/// - Parameter filenames: Array of filenames to validate
/// - Returns: Dictionary with validation results
static func validateFilenames(_ filenames: [String]) -> ImageValidationResult { static func validateFilenames(_ filenames: [String]) -> ImageValidationResult {
var validImages: [String] = [] var validImages: [String] = []
var invalidImages: [String] = [] var invalidImages: [String] = []
@@ -159,7 +115,7 @@ class ImageNamingUtils {
} }
} }
/// Result of image filename validation // Result of image filename validation
struct ImageValidationResult { struct ImageValidationResult {
let totalImages: Int let totalImages: Int
let validImages: [String] let validImages: [String]

View File

@@ -554,20 +554,20 @@ class ClimbingDataManager: ObservableObject {
// Collect referenced image paths // Collect referenced image paths
let referencedImagePaths = collectReferencedImagePaths() let referencedImagePaths = collectReferencedImagePaths()
print("🎯 Starting export with \(referencedImagePaths.count) images") print("Starting export with \(referencedImagePaths.count) images")
let zipData = try ZipUtils.createExportZip( let zipData = try ZipUtils.createExportZip(
exportData: exportData, exportData: exportData,
referencedImagePaths: referencedImagePaths referencedImagePaths: referencedImagePaths
) )
print("Export completed successfully") print("Export completed successfully")
successMessage = "Export completed with \(referencedImagePaths.count) images" successMessage = "Export completed with \(referencedImagePaths.count) images"
clearMessageAfterDelay() clearMessageAfterDelay()
return zipData return zipData
} catch { } catch {
let errorMessage = "Export failed: \(error.localizedDescription)" let errorMessage = "Export failed: \(error.localizedDescription)"
print(" \(errorMessage)") print("ERROR: \(errorMessage)")
setError(errorMessage) setError(errorMessage)
return nil return nil
} }
@@ -662,13 +662,13 @@ class ClimbingDataManager: ObservableObject {
extension ClimbingDataManager { extension ClimbingDataManager {
private func collectReferencedImagePaths() -> Set<String> { private func collectReferencedImagePaths() -> Set<String> {
var imagePaths = Set<String>() var imagePaths = Set<String>()
print("🖼️ Starting image path collection...") print("Starting image path collection...")
print("📊 Total problems: \(problems.count)") print("Total problems: \(problems.count)")
for problem in problems { for problem in problems {
if !problem.imagePaths.isEmpty { if !problem.imagePaths.isEmpty {
print( print(
"📸 Problem '\(problem.name ?? "Unnamed")' has \(problem.imagePaths.count) images" "Problem '\(problem.name ?? "Unnamed")' has \(problem.imagePaths.count) images"
) )
for imagePath in problem.imagePaths { for imagePath in problem.imagePaths {
print(" - Relative path: \(imagePath)") print(" - Relative path: \(imagePath)")
@@ -677,10 +677,10 @@ extension ClimbingDataManager {
// Check if file exists // Check if file exists
if FileManager.default.fileExists(atPath: fullPath) { if FileManager.default.fileExists(atPath: fullPath) {
print(" File exists") print(" File exists")
imagePaths.insert(fullPath) imagePaths.insert(fullPath)
} else { } else {
print(" File does NOT exist") print(" File does NOT exist")
// Still add it to let ZipUtils handle the error logging // Still add it to let ZipUtils handle the error logging
imagePaths.insert(fullPath) 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 return imagePaths
} }
@@ -748,7 +748,7 @@ extension ClimbingDataManager {
// Log storage information for debugging // Log storage information for debugging
let info = await ImageManager.shared.getStorageInfo() let info = await ImageManager.shared.getStorageInfo()
print( 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 }.value
} }
@@ -786,7 +786,7 @@ extension ClimbingDataManager {
} }
if !orphanedFiles.isEmpty { 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() { func forceImageRecovery() {
print("🚨 User initiated force image recovery") print("User initiated force image recovery")
ImageManager.shared.forceRecoveryMigration() ImageManager.shared.forceRecoveryMigration()
// Refresh the UI after recovery // Refresh the UI after recovery
@@ -811,7 +811,7 @@ extension ClimbingDataManager {
} }
func emergencyImageRestore() { func emergencyImageRestore() {
print("🆘 User initiated emergency image restore") print("User initiated emergency image restore")
ImageManager.shared.emergencyImageRestore() ImageManager.shared.emergencyImageRestore()
// Refresh the UI after restore // Refresh the UI after restore
@@ -827,7 +827,7 @@ extension ClimbingDataManager {
let info = ImageManager.shared.getStorageInfo() let info = ImageManager.shared.getStorageInfo()
return """ return """
Image Storage Health: \(isValid ? "Good" : "Needs Recovery") Image Storage Health: \(isValid ? "Good" : "Needs Recovery")
Primary Files: \(info.primaryCount) Primary Files: \(info.primaryCount)
Backup Files: \(info.backupCount) Backup Files: \(info.backupCount)
Total Size: \(formatBytes(info.totalSize)) Total Size: \(formatBytes(info.totalSize))
@@ -845,7 +845,7 @@ extension ClimbingDataManager {
// Test with dummy data if we have a gym // Test with dummy data if we have a gym
guard let testGym = gyms.first else { guard let testGym = gyms.first else {
print(" No gyms available for testing") print("ERROR: No gyms available for testing")
return return
} }
@@ -877,14 +877,14 @@ extension ClimbingDataManager {
// Only restart if session is actually active // Only restart if session is actually active
guard activeSession.status == .active else { guard activeSession.status == .active else {
print( 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() await LiveActivityManager.shared.endLiveActivity()
return return
} }
if let gym = gym(withId: activeSession.gymId) { 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 // First cleanup any dismissed activities
await LiveActivityManager.shared.cleanupDismissedActivities() await LiveActivityManager.shared.cleanupDismissedActivities()
@@ -894,15 +894,12 @@ extension ClimbingDataManager {
activeSession: activeSession, activeSession: activeSession,
gymName: gym.name gymName: gym.name
) )
// Update with current session data
await updateLiveActivityData()
} }
} }
/// Call this when app becomes active to check for Live Activity restart /// Call this when app becomes active to check for Live Activity restart
func onAppBecomeActive() { func onAppBecomeActive() {
print("📱 App became active - checking Live Activity status") print("App became active - checking Live Activity status")
Task { Task {
await checkAndRestartLiveActivity() await checkAndRestartLiveActivity()
} }
@@ -910,7 +907,7 @@ extension ClimbingDataManager {
/// Call this when app enters background to update Live Activity /// Call this when app enters background to update Live Activity
func onAppEnterBackground() { func onAppEnterBackground() {
print("📱 App entering background - updating Live Activity if needed") print("App entering background - updating Live Activity if needed")
Task { Task {
await updateLiveActivityData() await updateLiveActivityData()
} }
@@ -939,7 +936,7 @@ extension ClimbingDataManager {
return 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 // Wait a bit before restarting to avoid frequency limits
try? await Task.sleep(nanoseconds: 2_000_000_000) // 2 seconds try? await Task.sleep(nanoseconds: 2_000_000_000) // 2 seconds
@@ -979,7 +976,7 @@ extension ClimbingDataManager {
activeSession.status == .active, activeSession.status == .active,
let gym = gym(withId: activeSession.gymId) let gym = gym(withId: activeSession.gymId)
else { 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 { if let session = activeSession {
print(" Session ID: \(session.id)") print(" Session ID: \(session.id)")
print(" Session Status: \(session.status)") print(" Session Status: \(session.status)")
@@ -1003,7 +1000,7 @@ extension ClimbingDataManager {
elapsedInterval = 0 elapsedInterval = 0
} }
print("🔄 Live Activity Update Debug:") print("Live Activity Update Debug:")
print(" Session ID: \(activeSession.id)") print(" Session ID: \(activeSession.id)")
print(" Gym: \(gym.name)") print(" Gym: \(gym.name)")
print(" Total attempts in session: \(totalAttempts)") print(" Total attempts in session: \(totalAttempts)")

View File

@@ -34,11 +34,11 @@ final class LiveActivityManager {
let isStillActive = activities.contains { $0.id == currentActivity.id } let isStillActive = activities.contains { $0.id == currentActivity.id }
if isStillActive { if isStillActive {
print(" Live Activity still running: \(currentActivity.id)") print("Live Activity still running: \(currentActivity.id)")
return return
} else { } else {
print( print(
"⚠️ Tracked Live Activity \(currentActivity.id) was dismissed, clearing reference" "WARNING: Tracked Live Activity \(currentActivity.id) was dismissed, clearing reference"
) )
self.currentActivity = nil self.currentActivity = nil
} }
@@ -47,18 +47,18 @@ final class LiveActivityManager {
// Check if there are ANY active Live Activities for this session // Check if there are ANY active Live Activities for this session
let existingActivities = Activity<SessionActivityAttributes>.activities let existingActivities = Activity<SessionActivityAttributes>.activities
if let existingActivity = existingActivities.first { 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 self.currentActivity = existingActivity
return 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) await startLiveActivity(for: activeSession, gymName: gymName)
} }
/// Call this when a ClimbSession starts to begin a Live Activity /// Call this when a ClimbSession starts to begin a Live Activity
func startLiveActivity(for session: ClimbSession, gymName: String) async { func startLiveActivity(for session: ClimbSession, gymName: String) async {
print("🔴 Starting Live Activity for gym: \(gymName)") print("Starting Live Activity for gym: \(gymName)")
await endLiveActivity() await endLiveActivity()
@@ -84,9 +84,9 @@ final class LiveActivityManager {
pushType: nil pushType: nil
) )
self.currentActivity = activity self.currentActivity = activity
print("Live Activity started successfully: \(activity.id)") print("Live Activity started successfully: \(activity.id)")
} catch { } catch {
print(" Failed to start live activity: \(error)") print("ERROR: Failed to start live activity: \(error)")
print("Error details: \(error.localizedDescription)") print("Error details: \(error.localizedDescription)")
// Check specific error types // Check specific error types
@@ -104,7 +104,7 @@ final class LiveActivityManager {
func updateLiveActivity(elapsed: TimeInterval, totalAttempts: Int, completedProblems: Int) async func updateLiveActivity(elapsed: TimeInterval, totalAttempts: Int, completedProblems: Int) async
{ {
guard let currentActivity = currentActivity else { guard let currentActivity = currentActivity else {
print("⚠️ No current activity to update") print("WARNING: No current activity to update")
return return
} }
@@ -114,14 +114,14 @@ final class LiveActivityManager {
if !isStillActive { if !isStillActive {
print( 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 self.currentActivity = nil
return return
} }
print( print(
"🔄 Updating Live Activity - Attempts: \(totalAttempts), Completed: \(completedProblems)" "Updating Live Activity - Attempts: \(totalAttempts), Completed: \(completedProblems)"
) )
let updatedContentState = SessionActivityAttributes.ContentState( let updatedContentState = SessionActivityAttributes.ContentState(
@@ -131,7 +131,7 @@ final class LiveActivityManager {
) )
await currentActivity.update(.init(state: updatedContentState, staleDate: nil)) 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 /// 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 // First end the tracked activity if it exists
if let currentActivity { if let currentActivity {
print("🔴 Ending tracked Live Activity: \(currentActivity.id)") print("Ending tracked Live Activity: \(currentActivity.id)")
await currentActivity.end(nil, dismissalPolicy: .immediate) await currentActivity.end(nil, dismissalPolicy: .immediate)
self.currentActivity = nil 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 // 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<SessionActivityAttributes>.activities let activities = Activity<SessionActivityAttributes>.activities
if activities.isEmpty { if activities.isEmpty {
print(" No additional activities found") print("No additional activities found")
} else { } else {
print("🔴 Found \(activities.count) additional active activities, ending them...") print("Found \(activities.count) additional active activities, ending them...")
for activity in activities { for activity in activities {
print("🔴 Force ending activity: \(activity.id)") print("Force ending activity: \(activity.id)")
await activity.end(nil, dismissalPolicy: .immediate) 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 { if let currentActivity = currentActivity {
let isStillActive = activities.contains { $0.id == currentActivity.id } let isStillActive = activities.contains { $0.id == currentActivity.id }
if !isStillActive { if !isStillActive {
print("🧹 Cleaning up dismissed Live Activity: \(currentActivity.id)") print("Cleaning up dismissed Live Activity: \(currentActivity.id)")
self.currentActivity = nil self.currentActivity = nil
} }
} }
@@ -211,7 +211,7 @@ final class LiveActivityManager {
func stopHealthChecks() { func stopHealthChecks() {
healthCheckTimer?.invalidate() healthCheckTimer?.invalidate()
healthCheckTimer = nil healthCheckTimer = nil
print("🛑 Stopped Live Activity health checks") print("Stopped Live Activity health checks")
} }
/// Perform a health check on the current Live Activity /// Perform a health check on the current Live Activity
@@ -231,7 +231,7 @@ final class LiveActivityManager {
let isStillActive = activities.contains { $0.id == currentActivity.id } let isStillActive = activities.contains { $0.id == currentActivity.id }
if !isStillActive { if !isStillActive {
print("💔 Health check failed - Live Activity was dismissed") print("Health check failed - Live Activity was dismissed")
self.currentActivity = nil self.currentActivity = nil
// Notify that we need to restart // Notify that we need to restart
@@ -240,7 +240,7 @@ final class LiveActivityManager {
object: nil object: nil
) )
} else { } else {
print("Live Activity health check passed") print("Live Activity health check passed")
} }
} }

View File

@@ -42,7 +42,7 @@ struct AddAttemptView: View {
} }
var body: some View { var body: some View {
NavigationView { NavigationStack {
Form { Form {
if !showingCreateProblem { if !showingCreateProblem {
ProblemSelectionSection() ProblemSelectionSection()
@@ -597,7 +597,7 @@ struct ProblemExpandedView: View {
@State private var selectedImageIndex = 0 @State private var selectedImageIndex = 0
var body: some View { var body: some View {
NavigationView { NavigationStack {
ScrollView { ScrollView {
VStack(alignment: .leading, spacing: 16) { VStack(alignment: .leading, spacing: 16) {
// Images // Images
@@ -735,7 +735,7 @@ struct EditAttemptView: View {
} }
var body: some View { var body: some View {
NavigationView { NavigationStack {
Form { Form {
if !showingCreateProblem { if !showingCreateProblem {
ProblemSelectionSection() ProblemSelectionSection()

View File

@@ -1,4 +1,3 @@
import SwiftUI import SwiftUI
struct AddEditGymView: View { struct AddEditGymView: View {
@@ -34,7 +33,7 @@ struct AddEditGymView: View {
} }
var body: some View { var body: some View {
NavigationView { NavigationStack {
Form { Form {
BasicInfoSection() BasicInfoSection()
ClimbTypesSection() ClimbTypesSection()

View File

@@ -55,7 +55,7 @@ struct AddEditProblemView: View {
} }
var body: some View { var body: some View {
NavigationView { NavigationStack {
Form { Form {
GymSelectionSection() GymSelectionSection()
BasicInfoSection() BasicInfoSection()

View File

@@ -1,4 +1,3 @@
import SwiftUI import SwiftUI
struct AddEditSessionView: View { struct AddEditSessionView: View {
@@ -21,7 +20,7 @@ struct AddEditSessionView: View {
} }
var body: some View { var body: some View {
NavigationView { NavigationStack {
Form { Form {
GymSelectionSection() GymSelectionSection()
SessionDetailsSection() SessionDetailsSection()

View File

@@ -4,7 +4,7 @@ struct AnalyticsView: View {
@EnvironmentObject var dataManager: ClimbingDataManager @EnvironmentObject var dataManager: ClimbingDataManager
var body: some View { var body: some View {
NavigationView { NavigationStack {
ScrollView { ScrollView {
LazyVStack(spacing: 20) { LazyVStack(spacing: 20) {
OverallStatsSection() OverallStatsSection()

View File

@@ -420,7 +420,7 @@ struct ImageViewerView: View {
} }
var body: some View { var body: some View {
NavigationView { NavigationStack {
TabView(selection: $currentIndex) { TabView(selection: $currentIndex) {
ForEach(imagePaths.indices, id: \.self) { index in ForEach(imagePaths.indices, id: \.self) { index in
ProblemDetailImageFullView(imagePath: imagePaths[index]) ProblemDetailImageFullView(imagePath: imagePaths[index])

View File

@@ -9,24 +9,11 @@ struct SessionDetailView: View {
@State private var showingAddAttempt = false @State private var showingAddAttempt = false
@State private var editingAttempt: Attempt? @State private var editingAttempt: Attempt?
@State private var attemptToDelete: Attempt? @State private var attemptToDelete: Attempt?
@State private var currentTime = Date()
private var session: ClimbSession? { private var session: ClimbSession? {
dataManager.session(withId: sessionId) 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? { private var gym: Gym? {
guard let session = session else { return nil } guard let session = session else { return nil }
return dataManager.gym(withId: session.gymId) return dataManager.gym(withId: session.gymId)
@@ -47,14 +34,12 @@ struct SessionDetailView: View {
calculateSessionStats() calculateSessionStats()
} }
@State private var timer: Timer?
var body: some View { var body: some View {
ScrollView { ScrollView {
LazyVStack(spacing: 20) { LazyVStack(spacing: 20) {
if let session = session, let gym = gym { if let session = session, let gym = gym {
SessionHeaderCard( SessionHeaderCard(
session: session, gym: gym, stats: sessionStats, currentTime: currentTime) session: session, gym: gym, stats: sessionStats)
SessionStatsCard(stats: sessionStats) SessionStatsCard(stats: sessionStats)
@@ -69,12 +54,7 @@ struct SessionDetailView: View {
} }
.padding() .padding()
} }
.onAppear {
startTimer()
}
.onDisappear {
stopTimer()
}
.navigationTitle("Session Details") .navigationTitle("Session Details")
.navigationBarTitleDisplayMode(.inline) .navigationBarTitleDisplayMode(.inline)
.toolbar { .toolbar {
@@ -182,7 +162,6 @@ struct SessionHeaderCard: View {
let session: ClimbSession let session: ClimbSession
let gym: Gym let gym: Gym
let stats: SessionStats let stats: SessionStats
let currentTime: Date
var body: some View { var body: some View {
VStack(alignment: .leading, spacing: 16) { VStack(alignment: .leading, spacing: 16) {
@@ -197,9 +176,13 @@ struct SessionHeaderCard: View {
if session.status == .active { if session.status == .active {
if let startTime = session.startTime { if let startTime = session.startTime {
Text("Duration: \(formatDuration(from: startTime, to: currentTime))") Text("Duration: ")
.font(.subheadline) .font(.subheadline)
.foregroundColor(.secondary) .foregroundColor(.secondary)
+ Text(timerInterval: startTime...Date.distantFuture, countsDown: false)
.font(.subheadline)
.foregroundColor(.secondary)
.monospacedDigit()
} }
} else if let duration = session.duration { } else if let duration = session.duration {
Text("Duration: \(duration) minutes") Text("Duration: \(duration) minutes")
@@ -246,20 +229,6 @@ struct SessionHeaderCard: View {
return formatter.string(from: date) 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 { struct SessionStatsCard: View {

View File

@@ -5,7 +5,7 @@ struct GymsView: View {
@State private var showingAddGym = false @State private var showingAddGym = false
var body: some View { var body: some View {
NavigationView { NavigationStack {
VStack { VStack {
if dataManager.gyms.isEmpty { if dataManager.gyms.isEmpty {
EmptyGymsView() EmptyGymsView()

View File

@@ -9,7 +9,7 @@ struct LiveActivityDebugView: View {
@State private var isTestRunning = false @State private var isTestRunning = false
var body: some View { var body: some View {
NavigationView { NavigationStack {
VStack(alignment: .leading, spacing: 20) { VStack(alignment: .leading, spacing: 20) {
// Header // Header
@@ -87,7 +87,7 @@ struct LiveActivityDebugView: View {
.disabled(dataManager.activeSession == nil) .disabled(dataManager.activeSession == nil)
if dataManager.gyms.isEmpty { 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) .font(.caption)
.foregroundColor(.orange) .foregroundColor(.orange)
} }
@@ -167,29 +167,31 @@ struct LiveActivityDebugView: View {
} }
private func checkStatus() { private func checkStatus() {
appendDebugOutput("🔍 Checking Live Activity status...") appendDebugOutput("Checking Live Activity status...")
let status = LiveActivityManager.shared.checkLiveActivityAvailability() let status = LiveActivityManager.shared.checkLiveActivityAvailability()
appendDebugOutput("Status: \(status)") appendDebugOutput("Status: \(status)")
// Check iOS version // Check iOS version
if #available(iOS 16.1, *) { if #available(iOS 16.1, *) {
appendDebugOutput("iOS version supports Live Activities") appendDebugOutput("iOS version supports Live Activities")
} else { } 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 // Check if we're on simulator
#if targetEnvironment(simulator) #if targetEnvironment(simulator)
appendDebugOutput("⚠️ Running on Simulator - Live Activities have limited functionality") appendDebugOutput(
"WARNING: Running on Simulator - Live Activities have limited functionality")
#else #else
appendDebugOutput("Running on device - Live Activities should work fully") appendDebugOutput("Running on device - Live Activities should work fully")
#endif #endif
} }
private func testLiveActivity() { private func testLiveActivity() {
guard !dataManager.gyms.isEmpty else { guard !dataManager.gyms.isEmpty else {
appendDebugOutput(" No gyms available for testing") appendDebugOutput("ERROR: No gyms available for testing")
return return
} }
@@ -240,25 +242,25 @@ struct LiveActivityDebugView: View {
appendDebugOutput("Ending Live Activity...") appendDebugOutput("Ending Live Activity...")
await LiveActivityManager.shared.endLiveActivity() await LiveActivityManager.shared.endLiveActivity()
appendDebugOutput("🏁 Live Activity test completed!") appendDebugOutput("Live Activity test completed!")
} }
} }
private func endCurrentSession() { private func endCurrentSession() {
guard let activeSession = dataManager.activeSession else { guard let activeSession = dataManager.activeSession else {
appendDebugOutput(" No active session to end") appendDebugOutput("ERROR: No active session to end")
return return
} }
appendDebugOutput("🛑 Ending current session: \(activeSession.id)") appendDebugOutput("Ending current session: \(activeSession.id)")
dataManager.endSession(activeSession.id) dataManager.endSession(activeSession.id)
appendDebugOutput("Session ended") appendDebugOutput("Session ended")
} }
private func forceLiveActivityUpdate() { private func forceLiveActivityUpdate() {
appendDebugOutput("🔄 Forcing Live Activity update...") appendDebugOutput("Forcing Live Activity update...")
dataManager.forceLiveActivityUpdate() dataManager.forceLiveActivityUpdate()
appendDebugOutput("Live Activity update sent") appendDebugOutput("Live Activity update sent")
} }
} }

View File

@@ -6,6 +6,8 @@ struct ProblemsView: View {
@State private var selectedClimbType: ClimbType? @State private var selectedClimbType: ClimbType?
@State private var selectedGym: Gym? @State private var selectedGym: Gym?
@State private var searchText = "" @State private var searchText = ""
@State private var showingSearch = false
@FocusState private var isSearchFocused: Bool
private var filteredProblems: [Problem] { private var filteredProblems: [Problem] {
var filtered = dataManager.problems var filtered = dataManager.problems
@@ -38,9 +40,46 @@ struct ProblemsView: View {
} }
var body: some View { var body: some View {
NavigationView { NavigationStack {
Group {
VStack(spacing: 0) { VStack(spacing: 0) {
if !dataManager.problems.isEmpty { if showingSearch {
HStack(spacing: 8) {
Image(systemName: "magnifyingglass")
.foregroundColor(.secondary)
.font(.system(size: 16, weight: .medium))
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( FilterSection(
selectedClimbType: $selectedClimbType, selectedClimbType: $selectedClimbType,
selectedGym: $selectedGym, selectedGym: $selectedGym,
@@ -59,8 +98,9 @@ struct ProblemsView: View {
ProblemsList(problems: filteredProblems) ProblemsList(problems: filteredProblems)
} }
} }
}
.navigationTitle("Problems") .navigationTitle("Problems")
.searchable(text: $searchText, prompt: "Search problems...") .navigationBarTitleDisplayMode(.automatic)
.toolbar { .toolbar {
ToolbarItemGroup(placement: .navigationBarTrailing) { ToolbarItemGroup(placement: .navigationBarTrailing) {
if dataManager.isSyncing { 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 { if !dataManager.gyms.isEmpty {
Button("Add") { Button("Add") {
showingAddProblem = true showingAddProblem = true

View File

@@ -6,7 +6,7 @@ struct SessionsView: View {
@State private var showingAddSession = false @State private var showingAddSession = false
var body: some View { var body: some View {
NavigationView { NavigationStack {
Group { Group {
if dataManager.sessions.isEmpty && dataManager.activeSession == nil { if dataManager.sessions.isEmpty && dataManager.activeSession == nil {
EmptySessionsView() EmptySessionsView()
@@ -53,7 +53,6 @@ struct SessionsView: View {
AddEditSessionView() AddEditSessionView()
} }
} }
.navigationViewStyle(.stack)
} }
} }
@@ -129,11 +128,8 @@ struct ActiveSessionBanner: View {
let session: ClimbSession let session: ClimbSession
let gym: Gym let gym: Gym
@EnvironmentObject var dataManager: ClimbingDataManager @EnvironmentObject var dataManager: ClimbingDataManager
@State private var currentTime = Date()
@State private var navigateToDetail = false @State private var navigateToDetail = false
@State private var timer: Timer?
var body: some View { var body: some View {
HStack { HStack {
VStack(alignment: .leading, spacing: 4) { VStack(alignment: .leading, spacing: 4) {
@@ -151,9 +147,10 @@ struct ActiveSessionBanner: View {
.foregroundColor(.secondary) .foregroundColor(.secondary)
if let startTime = session.startTime { if let startTime = session.startTime {
Text(formatDuration(from: startTime, to: currentTime)) Text(timerInterval: startTime...Date.distantFuture, countsDown: false)
.font(.caption) .font(.caption)
.foregroundColor(.secondary) .foregroundColor(.secondary)
.monospacedDigit()
} }
} }
.frame(maxWidth: .infinity, alignment: .leading) .frame(maxWidth: .infinity, alignment: .leading)
@@ -180,42 +177,12 @@ struct ActiveSessionBanner: View {
.fill(.green.opacity(0.1)) .fill(.green.opacity(0.1))
.stroke(.green.opacity(0.3), lineWidth: 1) .stroke(.green.opacity(0.3), lineWidth: 1)
) )
.onAppear {
startTimer()
}
.onDisappear {
stopTimer()
}
.navigationDestination(isPresented: $navigateToDetail) { .navigationDestination(isPresented: $navigateToDetail) {
SessionDetailView(sessionId: session.id) 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 { struct SessionRow: View {

View File

@@ -11,6 +11,7 @@ struct SettingsView: View {
@State private var activeSheet: SheetType? @State private var activeSheet: SheetType?
var body: some View { var body: some View {
NavigationStack {
List { List {
SyncSection() SyncSection()
.environmentObject(dataManager.syncService) .environmentObject(dataManager.syncService)
@@ -22,6 +23,7 @@ struct SettingsView: View {
AppInfoSection() AppInfoSection()
} }
.navigationTitle("Settings") .navigationTitle("Settings")
.navigationBarTitleDisplayMode(.automatic)
.toolbar { .toolbar {
ToolbarItem(placement: .navigationBarTrailing) { ToolbarItem(placement: .navigationBarTrailing) {
if dataManager.isSyncing { if dataManager.isSyncing {
@@ -58,6 +60,7 @@ struct SettingsView: View {
} }
} }
} }
}
extension SheetType: Identifiable { extension SheetType: Identifiable {
var id: String { var id: String {
@@ -191,7 +194,7 @@ struct ExportDataView: View {
@State private var isCreatingFile = true @State private var isCreatingFile = true
var body: some View { var body: some View {
NavigationView { NavigationStack {
VStack(spacing: 30) { VStack(spacing: 30) {
if isCreatingFile { if isCreatingFile {
// Loading state - more prominent // Loading state - more prominent
@@ -498,7 +501,7 @@ struct SyncSettingsView: View {
@State private var testResultMessage = "" @State private var testResultMessage = ""
var body: some View { var body: some View {
NavigationView { NavigationStack {
Form { Form {
Section { Section {
TextField("Server URL", text: $serverURL) TextField("Server URL", text: $serverURL)
@@ -691,7 +694,7 @@ struct ImportDataView: View {
@State private var showingDocumentPicker = false @State private var showingDocumentPicker = false
var body: some View { var body: some View {
NavigationView { NavigationStack {
VStack(spacing: 20) { VStack(spacing: 20) {
Image(systemName: "square.and.arrow.down") Image(systemName: "square.and.arrow.down")
.font(.system(size: 60)) .font(.system(size: 60))
@@ -705,7 +708,7 @@ struct ImportDataView: View {
Text("Import climbing data from a previously exported ZIP file.") Text("Import climbing data from a previously exported ZIP file.")
.multilineTextAlignment(.center) .multilineTextAlignment(.center)
Text("⚠️ Warning: This will replace all current data!") Text("WARNING: This will replace all current data!")
.font(.subheadline) .font(.subheadline)
.foregroundColor(.red) .foregroundColor(.red)
.multilineTextAlignment(.center) .multilineTextAlignment(.center)

View File

@@ -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)
}
}

479
sync/format_test.go Normal file
View File

@@ -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]
}

View File

@@ -1,3 +1,3 @@
module openclimb-sync module openclimb-sync
go 1.25 go 1.21

361
sync/main_test.go Normal file
View File

@@ -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)
}
})
}
}