Compare commits
14 Commits
ANDROID_1.
...
IOS_1.2.2
| Author | SHA1 | Date | |
|---|---|---|---|
|
b8f874a433
|
|||
|
4bbd422c09
|
|||
|
4e42985135
|
|||
|
ba1a7117d9
|
|||
|
1980ff802a
|
|||
|
73c4e41cac
|
|||
|
3ccd0ec7ea
|
|||
|
8fbb40d453
|
|||
| 016d427ff8 | |||
| 7cbb333287 | |||
| 2160ce30bd | |||
| de21e3270e | |||
|
f6e1cdcb5b
|
|||
|
fab587c351
|
5
.github/workflows/deploy.yml
vendored
5
.github/workflows/deploy.yml
vendored
@@ -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
21
PRIVACY.md
Normal 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
|
||||||
29
README.md
29
README.md
@@ -2,11 +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.4.2
|
|
||||||
- iOS: 1.0.1
|
|
||||||
|
|
||||||
## Download
|
## Download
|
||||||
|
|
||||||
For Android do one of the following:
|
For Android do one of the following:
|
||||||
@@ -18,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+
|
||||||
|
|||||||
@@ -16,8 +16,8 @@ android {
|
|||||||
applicationId = "com.atridad.openclimb"
|
applicationId = "com.atridad.openclimb"
|
||||||
minSdk = 31
|
minSdk = 31
|
||||||
targetSdk = 36
|
targetSdk = 36
|
||||||
versionCode = 28
|
versionCode = 29
|
||||||
versionName = "1.7.0"
|
versionName = "1.7.1"
|
||||||
|
|
||||||
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
|
||||||
}
|
|
||||||
@@ -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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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 */
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -17,7 +18,6 @@ import com.atridad.openclimb.utils.ImageUtils
|
|||||||
import java.io.IOException
|
import java.io.IOException
|
||||||
import java.time.Instant
|
import java.time.Instant
|
||||||
import java.util.concurrent.TimeUnit
|
import java.util.concurrent.TimeUnit
|
||||||
import kotlinx.coroutines.CoroutineScope
|
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
import kotlinx.coroutines.flow.StateFlow
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
@@ -27,7 +27,6 @@ import kotlinx.coroutines.launch
|
|||||||
import kotlinx.coroutines.sync.Mutex
|
import kotlinx.coroutines.sync.Mutex
|
||||||
import kotlinx.coroutines.sync.withLock
|
import kotlinx.coroutines.sync.withLock
|
||||||
import kotlinx.coroutines.withContext
|
import kotlinx.coroutines.withContext
|
||||||
import kotlinx.serialization.encodeToString
|
|
||||||
import kotlinx.serialization.json.Json
|
import kotlinx.serialization.json.Json
|
||||||
import okhttp3.MediaType.Companion.toMediaType
|
import okhttp3.MediaType.Companion.toMediaType
|
||||||
import okhttp3.OkHttpClient
|
import okhttp3.OkHttpClient
|
||||||
@@ -62,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()
|
||||||
|
|
||||||
@@ -91,13 +90,13 @@ class SyncService(private val context: Context, private val repository: ClimbRep
|
|||||||
var serverURL: String
|
var serverURL: String
|
||||||
get() = sharedPreferences.getString(Keys.SERVER_URL, "") ?: ""
|
get() = sharedPreferences.getString(Keys.SERVER_URL, "") ?: ""
|
||||||
set(value) {
|
set(value) {
|
||||||
sharedPreferences.edit().putString(Keys.SERVER_URL, value).apply()
|
sharedPreferences.edit { putString(Keys.SERVER_URL, value) }
|
||||||
}
|
}
|
||||||
|
|
||||||
var authToken: String
|
var authToken: String
|
||||||
get() = sharedPreferences.getString(Keys.AUTH_TOKEN, "") ?: ""
|
get() = sharedPreferences.getString(Keys.AUTH_TOKEN, "") ?: ""
|
||||||
set(value) {
|
set(value) {
|
||||||
sharedPreferences.edit().putString(Keys.AUTH_TOKEN, value).apply()
|
sharedPreferences.edit { putString(Keys.AUTH_TOKEN, value) }
|
||||||
}
|
}
|
||||||
|
|
||||||
val isConfigured: Boolean
|
val isConfigured: Boolean
|
||||||
@@ -110,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(kotlinx.coroutines.Dispatchers.IO).launch {
|
kotlinx.coroutines.CoroutineScope(Dispatchers.IO).launch { triggerAutoSync() }
|
||||||
triggerAutoSync()
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -154,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) {
|
||||||
@@ -237,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())
|
||||||
|
|
||||||
@@ -253,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"
|
||||||
@@ -326,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() ||
|
||||||
@@ -367,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)
|
||||||
|
|
||||||
@@ -391,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")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -412,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()
|
||||||
@@ -448,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)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -466,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(
|
||||||
@@ -491,21 +467,6 @@ class SyncService(private val context: Context, private val repository: ClimbRep
|
|||||||
return imagePathMapping
|
return imagePathMapping
|
||||||
}
|
}
|
||||||
|
|
||||||
private suspend fun syncImagesToServer() {
|
|
||||||
val allProblems = repository.getAllProblems().first()
|
|
||||||
val backup =
|
|
||||||
ClimbDataBackup(
|
|
||||||
exportedAt = DateFormatUtils.nowISO8601(),
|
|
||||||
version = "2.0",
|
|
||||||
formatVersion = "2.0",
|
|
||||||
gyms = emptyList(),
|
|
||||||
problems = allProblems.map { BackupProblem.fromProblem(it) },
|
|
||||||
sessions = emptyList(),
|
|
||||||
attempts = emptyList()
|
|
||||||
)
|
|
||||||
syncImagesForBackup(backup)
|
|
||||||
}
|
|
||||||
|
|
||||||
private suspend fun syncImagesForBackup(backup: ClimbDataBackup) {
|
private suspend fun syncImagesForBackup(backup: ClimbDataBackup) {
|
||||||
Log.d(TAG, "Starting image sync for backup with ${backup.problems.size} problems")
|
Log.d(TAG, "Starting image sync for backup with ${backup.problems.size} problems")
|
||||||
|
|
||||||
@@ -532,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,
|
||||||
@@ -549,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(
|
||||||
@@ -605,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()
|
||||||
@@ -616,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?.mapNotNull { 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
|
||||||
@@ -638,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
|
||||||
@@ -663,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())
|
||||||
@@ -672,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())
|
||||||
@@ -681,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}")
|
||||||
}
|
}
|
||||||
@@ -696,11 +644,6 @@ class SyncService(private val context: Context, private val repository: ClimbRep
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Converts milliseconds to ISO8601 timestamp */
|
|
||||||
private fun millisToISO8601(millis: Long): String {
|
|
||||||
return DateFormatUtils.millisToISO8601(millis)
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Fixes existing image paths in the database to include the proper directory structure. This
|
* Fixes existing image paths in the database to include the proper directory structure. This
|
||||||
* corrects paths like "problem_abc_0.jpg" to "problem_images/problem_abc_0.jpg"
|
* corrects paths like "problem_abc_0.jpg" to "problem_images/problem_abc_0.jpg"
|
||||||
@@ -718,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++
|
||||||
@@ -819,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
|
||||||
@@ -833,141 +774,6 @@ class SyncService(private val context: Context, private val repository: ClimbRep
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// DEPRECATED: Complex merge logic replaced with simple timestamp-based sync
|
|
||||||
// These methods are no longer used but kept for reference
|
|
||||||
@Deprecated("Use simple timestamp-based sync instead")
|
|
||||||
private fun performIntelligentMerge(
|
|
||||||
local: ClimbDataBackup,
|
|
||||||
server: ClimbDataBackup
|
|
||||||
): ClimbDataBackup {
|
|
||||||
Log.d(TAG, "Merging data - preserving all entities to prevent data loss")
|
|
||||||
|
|
||||||
val mergedGyms = mergeGyms(local.gyms, server.gyms)
|
|
||||||
val mergedProblems = mergeProblems(local.problems, server.problems)
|
|
||||||
val mergedSessions = mergeSessions(local.sessions, server.sessions)
|
|
||||||
val mergedAttempts = mergeAttempts(local.attempts, server.attempts)
|
|
||||||
|
|
||||||
Log.d(
|
|
||||||
TAG,
|
|
||||||
"Merge results: gyms=${mergedGyms.size}, problems=${mergedProblems.size}, " +
|
|
||||||
"sessions=${mergedSessions.size}, attempts=${mergedAttempts.size}"
|
|
||||||
)
|
|
||||||
|
|
||||||
return ClimbDataBackup(
|
|
||||||
exportedAt = DateFormatUtils.nowISO8601(),
|
|
||||||
version = "2.0",
|
|
||||||
formatVersion = "2.0",
|
|
||||||
gyms = mergedGyms,
|
|
||||||
problems = mergedProblems,
|
|
||||||
sessions = mergedSessions,
|
|
||||||
attempts = mergedAttempts
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun mergeGyms(local: List<BackupGym>, server: List<BackupGym>): List<BackupGym> {
|
|
||||||
val merged = mutableMapOf<String, BackupGym>()
|
|
||||||
|
|
||||||
// Add all local gyms
|
|
||||||
local.forEach { gym -> merged[gym.id] = gym }
|
|
||||||
|
|
||||||
// Add server gyms, preferring newer updates
|
|
||||||
server.forEach { serverGym ->
|
|
||||||
val localGym = merged[serverGym.id]
|
|
||||||
if (localGym == null || isNewerThan(serverGym.updatedAt, localGym.updatedAt)) {
|
|
||||||
merged[serverGym.id] = serverGym
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return merged.values.toList()
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun mergeProblems(
|
|
||||||
local: List<BackupProblem>,
|
|
||||||
server: List<BackupProblem>
|
|
||||||
): List<BackupProblem> {
|
|
||||||
val merged = mutableMapOf<String, BackupProblem>()
|
|
||||||
|
|
||||||
// Add all local problems
|
|
||||||
local.forEach { problem -> merged[problem.id] = problem }
|
|
||||||
|
|
||||||
// Add server problems, preferring newer updates
|
|
||||||
server.forEach { serverProblem ->
|
|
||||||
val localProblem = merged[serverProblem.id]
|
|
||||||
if (localProblem == null || isNewerThan(serverProblem.updatedAt, localProblem.updatedAt)
|
|
||||||
) {
|
|
||||||
// Merge image paths to preserve all images
|
|
||||||
val allImagePaths = mutableSetOf<String>()
|
|
||||||
localProblem?.imagePaths?.let { allImagePaths.addAll(it) }
|
|
||||||
serverProblem.imagePaths?.let { allImagePaths.addAll(it) }
|
|
||||||
|
|
||||||
merged[serverProblem.id] =
|
|
||||||
serverProblem.withUpdatedImagePaths(allImagePaths.toList())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return merged.values.toList()
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun mergeSessions(
|
|
||||||
local: List<BackupClimbSession>,
|
|
||||||
server: List<BackupClimbSession>
|
|
||||||
): List<BackupClimbSession> {
|
|
||||||
val merged = mutableMapOf<String, BackupClimbSession>()
|
|
||||||
|
|
||||||
// Add all local sessions
|
|
||||||
local.forEach { session -> merged[session.id] = session }
|
|
||||||
|
|
||||||
// Add server sessions, preferring newer updates
|
|
||||||
server.forEach { serverSession ->
|
|
||||||
val localSession = merged[serverSession.id]
|
|
||||||
if (localSession == null || isNewerThan(serverSession.updatedAt, localSession.updatedAt)
|
|
||||||
) {
|
|
||||||
merged[serverSession.id] = serverSession
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return merged.values.toList()
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun mergeAttempts(
|
|
||||||
local: List<BackupAttempt>,
|
|
||||||
server: List<BackupAttempt>
|
|
||||||
): List<BackupAttempt> {
|
|
||||||
val merged = mutableMapOf<String, BackupAttempt>()
|
|
||||||
|
|
||||||
// Add all local attempts
|
|
||||||
local.forEach { attempt -> merged[attempt.id] = attempt }
|
|
||||||
|
|
||||||
// Add server attempts, preferring newer updates
|
|
||||||
server.forEach { serverAttempt ->
|
|
||||||
val localAttempt = merged[serverAttempt.id]
|
|
||||||
if (localAttempt == null || isNewerThan(serverAttempt.createdAt, localAttempt.createdAt)
|
|
||||||
) {
|
|
||||||
merged[serverAttempt.id] = serverAttempt
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return merged.values.toList()
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun isNewerThan(dateString1: String, dateString2: String): Boolean {
|
|
||||||
return try {
|
|
||||||
// Try parsing as instant first
|
|
||||||
val date1 = Instant.parse(dateString1)
|
|
||||||
val date2 = Instant.parse(dateString2)
|
|
||||||
date1.isAfter(date2)
|
|
||||||
} catch (e: Exception) {
|
|
||||||
// Fallback to string comparison
|
|
||||||
dateString1 > dateString2
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fun disconnect() {
|
|
||||||
_isConnected.value = false
|
|
||||||
sharedPreferences.edit().putBoolean(Keys.IS_CONNECTED, false).apply()
|
|
||||||
_syncError.value = null
|
|
||||||
}
|
|
||||||
|
|
||||||
fun clearConfiguration() {
|
fun clearConfiguration() {
|
||||||
serverURL = ""
|
serverURL = ""
|
||||||
authToken = ""
|
authToken = ""
|
||||||
@@ -980,14 +786,11 @@ class SyncService(private val context: Context, private val repository: ClimbRep
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Removed SyncTrigger enum - now using simple auto sync on any data change
|
|
||||||
|
|
||||||
sealed class SyncException(message: String) : Exception(message) {
|
sealed class SyncException(message: String) : Exception(message) {
|
||||||
object NotConfigured :
|
object NotConfigured :
|
||||||
SyncException("Sync is not configured. Please set server URL and auth token.")
|
SyncException("Sync is not configured. Please set server URL and auth token.")
|
||||||
object NotConnected : SyncException("Not connected to server. Please test connection first.")
|
object NotConnected : SyncException("Not connected to server. Please test connection first.")
|
||||||
object Unauthorized : SyncException("Unauthorized. Please check your auth token.")
|
object Unauthorized : SyncException("Unauthorized. Please check your auth token.")
|
||||||
object InvalidURL : SyncException("Invalid server URL.")
|
|
||||||
data class ServerError(val code: Int) : SyncException("Server error: HTTP $code")
|
data class ServerError(val code: Int) : SyncException("Server error: HTTP $code")
|
||||||
data class InvalidResponse(val details: String) :
|
data class InvalidResponse(val details: String) :
|
||||||
SyncException("Invalid server response: $details")
|
SyncException("Invalid server response: $details")
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -0,0 +1,49 @@
|
|||||||
|
package com.atridad.openclimb.ui.components
|
||||||
|
|
||||||
|
import androidx.compose.animation.AnimatedVisibility
|
||||||
|
import androidx.compose.animation.fadeIn
|
||||||
|
import androidx.compose.animation.fadeOut
|
||||||
|
import androidx.compose.animation.scaleIn
|
||||||
|
import androidx.compose.animation.scaleOut
|
||||||
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.size
|
||||||
|
import androidx.compose.foundation.shape.CircleShape
|
||||||
|
import androidx.compose.material3.CircularProgressIndicator
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.collectAsState
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.draw.clip
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun SyncIndicator(isSyncing: StateFlow<Boolean>, modifier: Modifier = Modifier) {
|
||||||
|
val syncing by isSyncing.collectAsState()
|
||||||
|
|
||||||
|
AnimatedVisibility(
|
||||||
|
visible = syncing,
|
||||||
|
enter = scaleIn() + fadeIn(),
|
||||||
|
exit = scaleOut() + fadeOut(),
|
||||||
|
modifier = modifier
|
||||||
|
) {
|
||||||
|
Box(
|
||||||
|
modifier =
|
||||||
|
Modifier.size(28.dp)
|
||||||
|
.clip(CircleShape)
|
||||||
|
.background(MaterialTheme.colorScheme.primaryContainer, CircleShape)
|
||||||
|
.padding(6.dp),
|
||||||
|
contentAlignment = Alignment.Center
|
||||||
|
) {
|
||||||
|
CircularProgressIndicator(
|
||||||
|
modifier = Modifier.size(16.dp),
|
||||||
|
strokeWidth = 2.dp,
|
||||||
|
color = MaterialTheme.colorScheme.primary
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -15,6 +15,7 @@ import com.atridad.openclimb.data.model.ClimbType
|
|||||||
import com.atridad.openclimb.data.model.DifficultySystem
|
import com.atridad.openclimb.data.model.DifficultySystem
|
||||||
import com.atridad.openclimb.ui.components.BarChart
|
import com.atridad.openclimb.ui.components.BarChart
|
||||||
import com.atridad.openclimb.ui.components.BarChartDataPoint
|
import com.atridad.openclimb.ui.components.BarChartDataPoint
|
||||||
|
import com.atridad.openclimb.ui.components.SyncIndicator
|
||||||
import com.atridad.openclimb.ui.viewmodel.ClimbViewModel
|
import com.atridad.openclimb.ui.viewmodel.ClimbViewModel
|
||||||
import java.time.LocalDateTime
|
import java.time.LocalDateTime
|
||||||
import java.time.format.DateTimeFormatter
|
import java.time.format.DateTimeFormatter
|
||||||
@@ -45,8 +46,10 @@ fun AnalyticsScreen(viewModel: ClimbViewModel) {
|
|||||||
Text(
|
Text(
|
||||||
text = "Analytics",
|
text = "Analytics",
|
||||||
style = MaterialTheme.typography.headlineMedium,
|
style = MaterialTheme.typography.headlineMedium,
|
||||||
fontWeight = FontWeight.Bold
|
fontWeight = FontWeight.Bold,
|
||||||
|
modifier = Modifier.weight(1f)
|
||||||
)
|
)
|
||||||
|
SyncIndicator(isSyncing = viewModel.syncService.isSyncing)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -197,7 +200,7 @@ fun GradeDistributionChartCard(gradeDistributionData: List<GradeDistributionData
|
|||||||
},
|
},
|
||||||
modifier =
|
modifier =
|
||||||
Modifier.menuAnchor(
|
Modifier.menuAnchor(
|
||||||
type = MenuAnchorType.PrimaryNotEditable,
|
type = ExposedDropdownMenuAnchorType.PrimaryNotEditable,
|
||||||
enabled = true
|
enabled = true
|
||||||
)
|
)
|
||||||
.width(120.dp),
|
.width(120.dp),
|
||||||
@@ -253,7 +256,7 @@ fun GradeDistributionChartCard(gradeDistributionData: List<GradeDistributionData
|
|||||||
DateTimeFormatter.ISO_LOCAL_DATE_TIME
|
DateTimeFormatter.ISO_LOCAL_DATE_TIME
|
||||||
)
|
)
|
||||||
attemptDate.isAfter(sevenDaysAgo)
|
attemptDate.isAfter(sevenDaysAgo)
|
||||||
} catch (e: Exception) {
|
} catch (_: Exception) {
|
||||||
// If date parsing fails, include the data point
|
// If date parsing fails, include the data point
|
||||||
true
|
true
|
||||||
}
|
}
|
||||||
@@ -425,9 +428,7 @@ fun calculateGradeDistribution(
|
|||||||
} else {
|
} else {
|
||||||
gradeDistribution[key] =
|
gradeDistribution[key] =
|
||||||
GradeDistributionDataPoint(
|
GradeDistributionDataPoint(
|
||||||
date =
|
date = attempt.timestamp,
|
||||||
attempt.timestamp
|
|
||||||
.toString(), // Use attempt timestamp for filtering
|
|
||||||
grade = problem.difficulty.grade,
|
grade = problem.difficulty.grade,
|
||||||
gradeNumeric =
|
gradeNumeric =
|
||||||
gradeToNumeric(
|
gradeToNumeric(
|
||||||
|
|||||||
@@ -12,21 +12,15 @@ import androidx.compose.ui.text.font.FontWeight
|
|||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import com.atridad.openclimb.R
|
import com.atridad.openclimb.R
|
||||||
import com.atridad.openclimb.data.model.Gym
|
import com.atridad.openclimb.data.model.Gym
|
||||||
|
import com.atridad.openclimb.ui.components.SyncIndicator
|
||||||
import com.atridad.openclimb.ui.viewmodel.ClimbViewModel
|
import com.atridad.openclimb.ui.viewmodel.ClimbViewModel
|
||||||
|
|
||||||
@OptIn(ExperimentalMaterial3Api::class)
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
@Composable
|
@Composable
|
||||||
fun GymsScreen(
|
fun GymsScreen(viewModel: ClimbViewModel, onNavigateToGymDetail: (String) -> Unit) {
|
||||||
viewModel: ClimbViewModel,
|
|
||||||
onNavigateToGymDetail: (String) -> Unit
|
|
||||||
) {
|
|
||||||
val gyms by viewModel.gyms.collectAsState()
|
val gyms by viewModel.gyms.collectAsState()
|
||||||
|
|
||||||
Column(
|
Column(modifier = Modifier.fillMaxSize().padding(16.dp)) {
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxSize()
|
|
||||||
.padding(16.dp)
|
|
||||||
) {
|
|
||||||
Row(
|
Row(
|
||||||
modifier = Modifier.fillMaxWidth(),
|
modifier = Modifier.fillMaxWidth(),
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
@@ -41,8 +35,10 @@ fun GymsScreen(
|
|||||||
Text(
|
Text(
|
||||||
text = "Climbing Gyms",
|
text = "Climbing Gyms",
|
||||||
style = MaterialTheme.typography.headlineMedium,
|
style = MaterialTheme.typography.headlineMedium,
|
||||||
fontWeight = FontWeight.Bold
|
fontWeight = FontWeight.Bold,
|
||||||
|
modifier = Modifier.weight(1f)
|
||||||
)
|
)
|
||||||
|
SyncIndicator(isSyncing = viewModel.syncService.isSyncing)
|
||||||
}
|
}
|
||||||
|
|
||||||
Spacer(modifier = Modifier.height(16.dp))
|
Spacer(modifier = Modifier.height(16.dp))
|
||||||
@@ -57,10 +53,7 @@ fun GymsScreen(
|
|||||||
} else {
|
} else {
|
||||||
LazyColumn {
|
LazyColumn {
|
||||||
items(gyms) { gym ->
|
items(gyms) { gym ->
|
||||||
GymCard(
|
GymCard(gym = gym, onClick = { onNavigateToGymDetail(gym.id) })
|
||||||
gym = gym,
|
|
||||||
onClick = { onNavigateToGymDetail(gym.id) }
|
|
||||||
)
|
|
||||||
Spacer(modifier = Modifier.height(8.dp))
|
Spacer(modifier = Modifier.height(8.dp))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -70,19 +63,9 @@ fun GymsScreen(
|
|||||||
|
|
||||||
@OptIn(ExperimentalMaterial3Api::class)
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
@Composable
|
@Composable
|
||||||
fun GymCard(
|
fun GymCard(gym: Gym, onClick: () -> Unit) {
|
||||||
gym: Gym,
|
Card(onClick = onClick, modifier = Modifier.fillMaxWidth()) {
|
||||||
onClick: () -> Unit
|
Column(modifier = Modifier.fillMaxWidth().padding(16.dp)) {
|
||||||
) {
|
|
||||||
Card(
|
|
||||||
onClick = onClick,
|
|
||||||
modifier = Modifier.fillMaxWidth()
|
|
||||||
) {
|
|
||||||
Column(
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxWidth()
|
|
||||||
.padding(16.dp)
|
|
||||||
) {
|
|
||||||
Text(
|
Text(
|
||||||
text = gym.name,
|
text = gym.name,
|
||||||
style = MaterialTheme.typography.titleLarge,
|
style = MaterialTheme.typography.titleLarge,
|
||||||
@@ -104,9 +87,7 @@ fun GymCard(
|
|||||||
gym.supportedClimbTypes.forEach { climbType ->
|
gym.supportedClimbTypes.forEach { climbType ->
|
||||||
AssistChip(
|
AssistChip(
|
||||||
onClick = {},
|
onClick = {},
|
||||||
label = {
|
label = { Text(climbType.getDisplayName()) },
|
||||||
Text(climbType.getDisplayName())
|
|
||||||
},
|
|
||||||
modifier = Modifier.padding(end = 4.dp)
|
modifier = Modifier.padding(end = 4.dp)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -115,7 +96,8 @@ fun GymCard(
|
|||||||
if (gym.difficultySystems.isNotEmpty()) {
|
if (gym.difficultySystems.isNotEmpty()) {
|
||||||
Spacer(modifier = Modifier.height(4.dp))
|
Spacer(modifier = Modifier.height(4.dp))
|
||||||
Text(
|
Text(
|
||||||
text = "Systems: ${gym.difficultySystems.joinToString(", ") { it.getDisplayName() }}",
|
text =
|
||||||
|
"Systems: ${gym.difficultySystems.joinToString(", ") { it.getDisplayName() }}",
|
||||||
style = MaterialTheme.typography.bodySmall,
|
style = MaterialTheme.typography.bodySmall,
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ import com.atridad.openclimb.data.model.Gym
|
|||||||
import com.atridad.openclimb.data.model.Problem
|
import com.atridad.openclimb.data.model.Problem
|
||||||
import com.atridad.openclimb.ui.components.FullscreenImageViewer
|
import com.atridad.openclimb.ui.components.FullscreenImageViewer
|
||||||
import com.atridad.openclimb.ui.components.ImageDisplay
|
import com.atridad.openclimb.ui.components.ImageDisplay
|
||||||
|
import com.atridad.openclimb.ui.components.SyncIndicator
|
||||||
import com.atridad.openclimb.ui.viewmodel.ClimbViewModel
|
import com.atridad.openclimb.ui.viewmodel.ClimbViewModel
|
||||||
|
|
||||||
@OptIn(ExperimentalMaterial3Api::class)
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
@@ -58,10 +59,12 @@ fun ProblemsScreen(viewModel: ClimbViewModel, onNavigateToProblemDetail: (String
|
|||||||
tint = MaterialTheme.colorScheme.primary
|
tint = MaterialTheme.colorScheme.primary
|
||||||
)
|
)
|
||||||
Text(
|
Text(
|
||||||
text = "Problems & Routes",
|
text = "Climbing Problems",
|
||||||
style = MaterialTheme.typography.headlineMedium,
|
style = MaterialTheme.typography.headlineMedium,
|
||||||
fontWeight = FontWeight.Bold
|
fontWeight = FontWeight.Bold,
|
||||||
|
modifier = Modifier.weight(1f)
|
||||||
)
|
)
|
||||||
|
SyncIndicator(isSyncing = viewModel.syncService.isSyncing)
|
||||||
}
|
}
|
||||||
|
|
||||||
Spacer(modifier = Modifier.height(16.dp))
|
Spacer(modifier = Modifier.height(16.dp))
|
||||||
|
|||||||
@@ -20,16 +20,14 @@ import com.atridad.openclimb.R
|
|||||||
import com.atridad.openclimb.data.model.ClimbSession
|
import com.atridad.openclimb.data.model.ClimbSession
|
||||||
import com.atridad.openclimb.data.model.SessionStatus
|
import com.atridad.openclimb.data.model.SessionStatus
|
||||||
import com.atridad.openclimb.ui.components.ActiveSessionBanner
|
import com.atridad.openclimb.ui.components.ActiveSessionBanner
|
||||||
|
import com.atridad.openclimb.ui.components.SyncIndicator
|
||||||
import com.atridad.openclimb.ui.viewmodel.ClimbViewModel
|
import com.atridad.openclimb.ui.viewmodel.ClimbViewModel
|
||||||
import java.time.LocalDateTime
|
import java.time.LocalDateTime
|
||||||
import java.time.format.DateTimeFormatter
|
import java.time.format.DateTimeFormatter
|
||||||
|
|
||||||
@OptIn(ExperimentalMaterial3Api::class)
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
@Composable
|
@Composable
|
||||||
fun SessionsScreen(
|
fun SessionsScreen(viewModel: ClimbViewModel, onNavigateToSessionDetail: (String) -> Unit) {
|
||||||
viewModel: ClimbViewModel,
|
|
||||||
onNavigateToSessionDetail: (String) -> Unit
|
|
||||||
) {
|
|
||||||
val context = LocalContext.current
|
val context = LocalContext.current
|
||||||
val sessions by viewModel.sessions.collectAsState()
|
val sessions by viewModel.sessions.collectAsState()
|
||||||
val gyms by viewModel.gyms.collectAsState()
|
val gyms by viewModel.gyms.collectAsState()
|
||||||
@@ -38,15 +36,9 @@ fun SessionsScreen(
|
|||||||
|
|
||||||
// Filter out active sessions from regular session list
|
// Filter out active sessions from regular session list
|
||||||
val completedSessions = sessions.filter { it.status == SessionStatus.COMPLETED }
|
val completedSessions = sessions.filter { it.status == SessionStatus.COMPLETED }
|
||||||
val activeSessionGym = activeSession?.let { session ->
|
val activeSessionGym = activeSession?.let { session -> gyms.find { it.id == session.gymId } }
|
||||||
gyms.find { it.id == session.gymId }
|
|
||||||
}
|
|
||||||
|
|
||||||
Column(
|
Column(modifier = Modifier.fillMaxSize().padding(16.dp)) {
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxSize()
|
|
||||||
.padding(16.dp)
|
|
||||||
) {
|
|
||||||
Row(
|
Row(
|
||||||
modifier = Modifier.fillMaxWidth(),
|
modifier = Modifier.fillMaxWidth(),
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
@@ -61,8 +53,10 @@ fun SessionsScreen(
|
|||||||
Text(
|
Text(
|
||||||
text = "Climbing Sessions",
|
text = "Climbing Sessions",
|
||||||
style = MaterialTheme.typography.headlineMedium,
|
style = MaterialTheme.typography.headlineMedium,
|
||||||
fontWeight = FontWeight.Bold
|
fontWeight = FontWeight.Bold,
|
||||||
|
modifier = Modifier.weight(1f)
|
||||||
)
|
)
|
||||||
|
SyncIndicator(isSyncing = viewModel.syncService.isSyncing)
|
||||||
}
|
}
|
||||||
|
|
||||||
Spacer(modifier = Modifier.height(16.dp))
|
Spacer(modifier = Modifier.height(16.dp))
|
||||||
@@ -71,14 +65,8 @@ fun SessionsScreen(
|
|||||||
ActiveSessionBanner(
|
ActiveSessionBanner(
|
||||||
activeSession = activeSession,
|
activeSession = activeSession,
|
||||||
gym = activeSessionGym,
|
gym = activeSessionGym,
|
||||||
onSessionClick = {
|
onSessionClick = { activeSession?.let { onNavigateToSessionDetail(it.id) } },
|
||||||
activeSession?.let { onNavigateToSessionDetail(it.id) }
|
onEndSession = { activeSession?.let { viewModel.endSession(context, it.id) } }
|
||||||
},
|
|
||||||
onEndSession = {
|
|
||||||
activeSession?.let {
|
|
||||||
viewModel.endSession(context, it.id)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
)
|
)
|
||||||
|
|
||||||
if (activeSession != null) {
|
if (activeSession != null) {
|
||||||
@@ -88,7 +76,10 @@ fun SessionsScreen(
|
|||||||
if (completedSessions.isEmpty() && activeSession == null) {
|
if (completedSessions.isEmpty() && activeSession == null) {
|
||||||
EmptyStateMessage(
|
EmptyStateMessage(
|
||||||
title = if (gyms.isEmpty()) "No Gyms Available" else "No Sessions Yet",
|
title = if (gyms.isEmpty()) "No Gyms Available" else "No Sessions Yet",
|
||||||
message = if (gyms.isEmpty()) "Add a gym first to start tracking your climbing sessions!" else "Start your first climbing session!",
|
message =
|
||||||
|
if (gyms.isEmpty())
|
||||||
|
"Add a gym first to start tracking your climbing sessions!"
|
||||||
|
else "Start your first climbing session!",
|
||||||
onActionClick = {},
|
onActionClick = {},
|
||||||
actionText = ""
|
actionText = ""
|
||||||
)
|
)
|
||||||
@@ -114,18 +105,15 @@ fun SessionsScreen(
|
|||||||
}
|
}
|
||||||
|
|
||||||
Card(
|
Card(
|
||||||
modifier = Modifier
|
modifier = Modifier.fillMaxWidth().padding(16.dp),
|
||||||
.fillMaxWidth()
|
colors =
|
||||||
.padding(16.dp),
|
CardDefaults.cardColors(
|
||||||
colors = CardDefaults.cardColors(
|
|
||||||
containerColor = MaterialTheme.colorScheme.primaryContainer
|
containerColor = MaterialTheme.colorScheme.primaryContainer
|
||||||
),
|
),
|
||||||
shape = RoundedCornerShape(12.dp)
|
shape = RoundedCornerShape(12.dp)
|
||||||
) {
|
) {
|
||||||
Row(
|
Row(
|
||||||
modifier = Modifier
|
modifier = Modifier.fillMaxWidth().padding(16.dp),
|
||||||
.fillMaxWidth()
|
|
||||||
.padding(16.dp),
|
|
||||||
verticalAlignment = Alignment.CenterVertically
|
verticalAlignment = Alignment.CenterVertically
|
||||||
) {
|
) {
|
||||||
Icon(
|
Icon(
|
||||||
@@ -150,18 +138,15 @@ fun SessionsScreen(
|
|||||||
}
|
}
|
||||||
|
|
||||||
Card(
|
Card(
|
||||||
modifier = Modifier
|
modifier = Modifier.fillMaxWidth().padding(16.dp),
|
||||||
.fillMaxWidth()
|
colors =
|
||||||
.padding(16.dp),
|
CardDefaults.cardColors(
|
||||||
colors = CardDefaults.cardColors(
|
|
||||||
containerColor = MaterialTheme.colorScheme.errorContainer
|
containerColor = MaterialTheme.colorScheme.errorContainer
|
||||||
),
|
),
|
||||||
shape = RoundedCornerShape(12.dp)
|
shape = RoundedCornerShape(12.dp)
|
||||||
) {
|
) {
|
||||||
Row(
|
Row(
|
||||||
modifier = Modifier
|
modifier = Modifier.fillMaxWidth().padding(16.dp),
|
||||||
.fillMaxWidth()
|
|
||||||
.padding(16.dp),
|
|
||||||
verticalAlignment = Alignment.CenterVertically
|
verticalAlignment = Alignment.CenterVertically
|
||||||
) {
|
) {
|
||||||
Icon(
|
Icon(
|
||||||
@@ -182,20 +167,9 @@ fun SessionsScreen(
|
|||||||
|
|
||||||
@OptIn(ExperimentalMaterial3Api::class)
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
@Composable
|
@Composable
|
||||||
fun SessionCard(
|
fun SessionCard(session: ClimbSession, gymName: String, onClick: () -> Unit) {
|
||||||
session: ClimbSession,
|
Card(onClick = onClick, modifier = Modifier.fillMaxWidth()) {
|
||||||
gymName: String,
|
Column(modifier = Modifier.fillMaxWidth().padding(16.dp)) {
|
||||||
onClick: () -> Unit
|
|
||||||
) {
|
|
||||||
Card(
|
|
||||||
onClick = onClick,
|
|
||||||
modifier = Modifier.fillMaxWidth()
|
|
||||||
) {
|
|
||||||
Column(
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxWidth()
|
|
||||||
.padding(16.dp)
|
|
||||||
) {
|
|
||||||
Row(
|
Row(
|
||||||
modifier = Modifier.fillMaxWidth(),
|
modifier = Modifier.fillMaxWidth(),
|
||||||
horizontalArrangement = Arrangement.SpaceBetween
|
horizontalArrangement = Arrangement.SpaceBetween
|
||||||
@@ -267,9 +241,7 @@ fun EmptyStateMessage(
|
|||||||
if (actionText.isNotEmpty()) {
|
if (actionText.isNotEmpty()) {
|
||||||
Spacer(modifier = Modifier.height(16.dp))
|
Spacer(modifier = Modifier.height(16.dp))
|
||||||
|
|
||||||
Button(onClick = onActionClick) {
|
Button(onClick = onActionClick) { Text(actionText) }
|
||||||
Text(actionText)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ import androidx.compose.ui.res.painterResource
|
|||||||
import androidx.compose.ui.text.font.FontWeight
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import com.atridad.openclimb.R
|
import com.atridad.openclimb.R
|
||||||
|
import com.atridad.openclimb.ui.components.SyncIndicator
|
||||||
import com.atridad.openclimb.ui.viewmodel.ClimbViewModel
|
import com.atridad.openclimb.ui.viewmodel.ClimbViewModel
|
||||||
import java.io.File
|
import java.io.File
|
||||||
import java.time.Instant
|
import java.time.Instant
|
||||||
@@ -122,153 +123,10 @@ fun SettingsScreen(viewModel: ClimbViewModel) {
|
|||||||
Text(
|
Text(
|
||||||
text = "Settings",
|
text = "Settings",
|
||||||
style = MaterialTheme.typography.headlineMedium,
|
style = MaterialTheme.typography.headlineMedium,
|
||||||
fontWeight = FontWeight.Bold
|
fontWeight = FontWeight.Bold,
|
||||||
|
modifier = Modifier.weight(1f)
|
||||||
)
|
)
|
||||||
}
|
SyncIndicator(isSyncing = viewModel.syncService.isSyncing)
|
||||||
}
|
|
||||||
|
|
||||||
// Data Management Section
|
|
||||||
item {
|
|
||||||
Card(modifier = Modifier.fillMaxWidth(), shape = RoundedCornerShape(16.dp)) {
|
|
||||||
Column(modifier = Modifier.fillMaxWidth().padding(16.dp)) {
|
|
||||||
Text(
|
|
||||||
text = "Data Management",
|
|
||||||
style = MaterialTheme.typography.titleMedium,
|
|
||||||
fontWeight = FontWeight.Bold
|
|
||||||
)
|
|
||||||
|
|
||||||
Spacer(modifier = Modifier.height(12.dp))
|
|
||||||
|
|
||||||
// Export Data
|
|
||||||
Card(
|
|
||||||
shape = RoundedCornerShape(12.dp),
|
|
||||||
colors =
|
|
||||||
CardDefaults.cardColors(
|
|
||||||
containerColor =
|
|
||||||
MaterialTheme.colorScheme.surfaceVariant.copy(
|
|
||||||
alpha = 0.3f
|
|
||||||
)
|
|
||||||
)
|
|
||||||
) {
|
|
||||||
ListItem(
|
|
||||||
headlineContent = { Text("Export Data with Images") },
|
|
||||||
supportingContent = {
|
|
||||||
Text(
|
|
||||||
"Export all your climbing data and images to ZIP file (recommended)"
|
|
||||||
)
|
|
||||||
},
|
|
||||||
leadingContent = {
|
|
||||||
Icon(Icons.Default.Share, contentDescription = null)
|
|
||||||
},
|
|
||||||
trailingContent = {
|
|
||||||
TextButton(
|
|
||||||
onClick = {
|
|
||||||
val defaultFileName =
|
|
||||||
"openclimb_export_${
|
|
||||||
java.time.LocalDateTime.now()
|
|
||||||
.toString()
|
|
||||||
.replace(":", "-")
|
|
||||||
.replace(".", "-")
|
|
||||||
}.zip"
|
|
||||||
exportZipLauncher.launch(defaultFileName)
|
|
||||||
},
|
|
||||||
enabled = !uiState.isLoading
|
|
||||||
) {
|
|
||||||
if (uiState.isLoading) {
|
|
||||||
CircularProgressIndicator(
|
|
||||||
modifier = Modifier.size(16.dp),
|
|
||||||
strokeWidth = 2.dp
|
|
||||||
)
|
|
||||||
} else {
|
|
||||||
Text("Export ZIP")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
Spacer(modifier = Modifier.height(8.dp))
|
|
||||||
|
|
||||||
Card(
|
|
||||||
shape = RoundedCornerShape(12.dp),
|
|
||||||
colors =
|
|
||||||
CardDefaults.cardColors(
|
|
||||||
containerColor =
|
|
||||||
MaterialTheme.colorScheme.surfaceVariant.copy(
|
|
||||||
alpha = 0.3f
|
|
||||||
)
|
|
||||||
)
|
|
||||||
) {
|
|
||||||
ListItem(
|
|
||||||
headlineContent = { Text("Import Data") },
|
|
||||||
supportingContent = {
|
|
||||||
Text("Import climbing data from ZIP file (recommended format)")
|
|
||||||
},
|
|
||||||
leadingContent = {
|
|
||||||
Icon(Icons.Default.Add, contentDescription = null)
|
|
||||||
},
|
|
||||||
trailingContent = {
|
|
||||||
TextButton(
|
|
||||||
onClick = { importLauncher.launch("application/zip") },
|
|
||||||
enabled = !uiState.isLoading
|
|
||||||
) {
|
|
||||||
if (uiState.isLoading) {
|
|
||||||
CircularProgressIndicator(
|
|
||||||
modifier = Modifier.size(16.dp),
|
|
||||||
strokeWidth = 2.dp
|
|
||||||
)
|
|
||||||
} else {
|
|
||||||
Text("Import")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
Spacer(modifier = Modifier.height(8.dp))
|
|
||||||
|
|
||||||
Card(
|
|
||||||
shape = RoundedCornerShape(12.dp),
|
|
||||||
colors =
|
|
||||||
CardDefaults.cardColors(
|
|
||||||
containerColor =
|
|
||||||
MaterialTheme.colorScheme.errorContainer.copy(
|
|
||||||
alpha = 0.3f
|
|
||||||
)
|
|
||||||
)
|
|
||||||
) {
|
|
||||||
ListItem(
|
|
||||||
headlineContent = { Text("Reset All Data") },
|
|
||||||
supportingContent = {
|
|
||||||
Text(
|
|
||||||
"Permanently delete all gyms, problems, sessions, attempts, and images"
|
|
||||||
)
|
|
||||||
},
|
|
||||||
leadingContent = {
|
|
||||||
Icon(
|
|
||||||
Icons.Default.Delete,
|
|
||||||
contentDescription = null,
|
|
||||||
tint = MaterialTheme.colorScheme.error
|
|
||||||
)
|
|
||||||
},
|
|
||||||
trailingContent = {
|
|
||||||
TextButton(
|
|
||||||
onClick = { showResetDialog = true },
|
|
||||||
enabled = !uiState.isLoading
|
|
||||||
) {
|
|
||||||
if (uiState.isLoading) {
|
|
||||||
CircularProgressIndicator(
|
|
||||||
modifier = Modifier.size(16.dp),
|
|
||||||
strokeWidth = 2.dp
|
|
||||||
)
|
|
||||||
} else {
|
|
||||||
Text("Reset", color = MaterialTheme.colorScheme.error)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -318,7 +176,7 @@ fun SettingsScreen(viewModel: ClimbViewModel) {
|
|||||||
"Last sync: ${
|
"Last sync: ${
|
||||||
try {
|
try {
|
||||||
Instant.parse(time).toString()
|
Instant.parse(time).toString()
|
||||||
} catch (e: Exception) {
|
} catch (_: Exception) {
|
||||||
time
|
time
|
||||||
}
|
}
|
||||||
}",
|
}",
|
||||||
@@ -510,6 +368,151 @@ fun SettingsScreen(viewModel: ClimbViewModel) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Data Management Section
|
||||||
|
item {
|
||||||
|
Card(modifier = Modifier.fillMaxWidth(), shape = RoundedCornerShape(16.dp)) {
|
||||||
|
Column(modifier = Modifier.fillMaxWidth().padding(16.dp)) {
|
||||||
|
Text(
|
||||||
|
text = "Data Management",
|
||||||
|
style = MaterialTheme.typography.titleMedium,
|
||||||
|
fontWeight = FontWeight.Bold
|
||||||
|
)
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.height(12.dp))
|
||||||
|
|
||||||
|
// Export Data
|
||||||
|
Card(
|
||||||
|
shape = RoundedCornerShape(12.dp),
|
||||||
|
colors =
|
||||||
|
CardDefaults.cardColors(
|
||||||
|
containerColor =
|
||||||
|
MaterialTheme.colorScheme.surfaceVariant.copy(
|
||||||
|
alpha = 0.3f
|
||||||
|
)
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
ListItem(
|
||||||
|
headlineContent = { Text("Export Data with Images") },
|
||||||
|
supportingContent = {
|
||||||
|
Text(
|
||||||
|
"Export all your climbing data and images to ZIP file (recommended)"
|
||||||
|
)
|
||||||
|
},
|
||||||
|
leadingContent = {
|
||||||
|
Icon(Icons.Default.Share, contentDescription = null)
|
||||||
|
},
|
||||||
|
trailingContent = {
|
||||||
|
TextButton(
|
||||||
|
onClick = {
|
||||||
|
val defaultFileName =
|
||||||
|
"openclimb_export_${
|
||||||
|
java.time.LocalDateTime.now()
|
||||||
|
.toString()
|
||||||
|
.replace(":", "-")
|
||||||
|
.replace(".", "-")
|
||||||
|
}.zip"
|
||||||
|
exportZipLauncher.launch(defaultFileName)
|
||||||
|
},
|
||||||
|
enabled = !uiState.isLoading
|
||||||
|
) {
|
||||||
|
if (uiState.isLoading) {
|
||||||
|
CircularProgressIndicator(
|
||||||
|
modifier = Modifier.size(16.dp),
|
||||||
|
strokeWidth = 2.dp
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
Text("Export ZIP")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.height(8.dp))
|
||||||
|
|
||||||
|
Card(
|
||||||
|
shape = RoundedCornerShape(12.dp),
|
||||||
|
colors =
|
||||||
|
CardDefaults.cardColors(
|
||||||
|
containerColor =
|
||||||
|
MaterialTheme.colorScheme.surfaceVariant.copy(
|
||||||
|
alpha = 0.3f
|
||||||
|
)
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
ListItem(
|
||||||
|
headlineContent = { Text("Import Data") },
|
||||||
|
supportingContent = {
|
||||||
|
Text("Import climbing data from ZIP file (recommended format)")
|
||||||
|
},
|
||||||
|
leadingContent = {
|
||||||
|
Icon(Icons.Default.Add, contentDescription = null)
|
||||||
|
},
|
||||||
|
trailingContent = {
|
||||||
|
TextButton(
|
||||||
|
onClick = { importLauncher.launch("application/zip") },
|
||||||
|
enabled = !uiState.isLoading
|
||||||
|
) {
|
||||||
|
if (uiState.isLoading) {
|
||||||
|
CircularProgressIndicator(
|
||||||
|
modifier = Modifier.size(16.dp),
|
||||||
|
strokeWidth = 2.dp
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
Text("Import")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.height(8.dp))
|
||||||
|
|
||||||
|
Card(
|
||||||
|
shape = RoundedCornerShape(12.dp),
|
||||||
|
colors =
|
||||||
|
CardDefaults.cardColors(
|
||||||
|
containerColor =
|
||||||
|
MaterialTheme.colorScheme.errorContainer.copy(
|
||||||
|
alpha = 0.3f
|
||||||
|
)
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
ListItem(
|
||||||
|
headlineContent = { Text("Reset All Data") },
|
||||||
|
supportingContent = {
|
||||||
|
Text(
|
||||||
|
"Permanently delete all gyms, problems, sessions, attempts, and images"
|
||||||
|
)
|
||||||
|
},
|
||||||
|
leadingContent = {
|
||||||
|
Icon(
|
||||||
|
Icons.Default.Delete,
|
||||||
|
contentDescription = null,
|
||||||
|
tint = MaterialTheme.colorScheme.error
|
||||||
|
)
|
||||||
|
},
|
||||||
|
trailingContent = {
|
||||||
|
TextButton(
|
||||||
|
onClick = { showResetDialog = true },
|
||||||
|
enabled = !uiState.isLoading
|
||||||
|
) {
|
||||||
|
if (uiState.isLoading) {
|
||||||
|
CircularProgressIndicator(
|
||||||
|
modifier = Modifier.size(16.dp),
|
||||||
|
strokeWidth = 2.dp
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
Text("Reset", color = MaterialTheme.colorScheme.error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// App Information Section
|
// App Information Section
|
||||||
item {
|
item {
|
||||||
Card(modifier = Modifier.fillMaxWidth(), shape = RoundedCornerShape(16.dp)) {
|
Card(modifier = Modifier.fillMaxWidth(), shape = RoundedCornerShape(16.dp)) {
|
||||||
@@ -754,7 +757,7 @@ fun SettingsScreen(viewModel: ClimbViewModel) {
|
|||||||
syncService.authToken = authToken.trim()
|
syncService.authToken = authToken.trim()
|
||||||
viewModel.testSyncConnection()
|
viewModel.testSyncConnection()
|
||||||
showSyncConfigDialog = false
|
showSyncConfigDialog = false
|
||||||
} catch (e: Exception) {
|
} catch (_: Exception) {
|
||||||
// Error will be shown via syncError state
|
// Error will be shown via syncError state
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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>()
|
||||||
|
|||||||
@@ -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() }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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,
|
||||||
|
|||||||
374
android/app/src/test/java/com/atridad/openclimb/UtilityTests.kt
Normal file
374
android/app/src/test/java/com/atridad/openclimb/UtilityTests.kt
Normal 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
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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" }
|
||||||
|
|||||||
BIN
android/gradle/wrapper/gradle-wrapper.jar
vendored
BIN
android/gradle/wrapper/gradle-wrapper.jar
vendored
Binary file not shown.
@@ -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
295
android/gradlew
vendored
@@ -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
36
android/gradlew.bat
vendored
@@ -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
|
||||||
|
|||||||
@@ -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}")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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()
|
||||||
|
|||||||
@@ -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 = 11;
|
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.0;
|
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 = 11;
|
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.0;
|
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 = 11;
|
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.0;
|
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 = 11;
|
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.0;
|
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 = (
|
||||||
|
|||||||
Binary file not shown.
@@ -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
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
import Combine
|
import Combine
|
||||||
import Foundation
|
import Foundation
|
||||||
import UIKit
|
|
||||||
|
|
||||||
@MainActor
|
@MainActor
|
||||||
class SyncService: ObservableObject {
|
class SyncService: ObservableObject {
|
||||||
@@ -231,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)
|
||||||
@@ -241,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...")
|
||||||
@@ -252,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(
|
||||||
@@ -262,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(
|
||||||
@@ -278,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 {
|
||||||
@@ -455,7 +454,7 @@ class SyncService: ObservableObject {
|
|||||||
let zipData = try createMinimalZipFromBackup(updatedBackup)
|
let zipData = try createMinimalZipFromBackup(updatedBackup)
|
||||||
|
|
||||||
// Use existing import method which properly handles data restoration
|
// Use existing import method which properly handles data restoration
|
||||||
try dataManager.importData(from: zipData)
|
try dataManager.importData(from: zipData, showSuccessMessage: false)
|
||||||
|
|
||||||
// Update local data state to match imported data timestamp
|
// Update local data state to match imported data timestamp
|
||||||
DataStateManager.shared.setLastModified(backup.exportedAt)
|
DataStateManager.shared.setLastModified(backup.exportedAt)
|
||||||
@@ -735,180 +734,29 @@ class SyncService: ObservableObject {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func triggerAutoSync(dataManager: ClimbingDataManager) {
|
func triggerAutoSync(dataManager: ClimbingDataManager) {
|
||||||
guard isConnected && isConfigured && isAutoSyncEnabled else { return }
|
// Early exit if sync cannot proceed - don't set isSyncing
|
||||||
|
guard isConnected && isConfigured && isAutoSyncEnabled else {
|
||||||
|
// Ensure isSyncing is false when sync is not possible
|
||||||
|
if isSyncing {
|
||||||
|
isSyncing = false
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Prevent multiple simultaneous syncs
|
||||||
|
guard !isSyncing else {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
Task {
|
Task {
|
||||||
do {
|
do {
|
||||||
try await syncWithServer(dataManager: dataManager)
|
try await syncWithServer(dataManager: dataManager)
|
||||||
} catch {
|
} catch {
|
||||||
print("Auto-sync failed: \(error)")
|
await MainActor.run {
|
||||||
// Don't show UI errors for auto-sync failures
|
self.isSyncing = false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// DEPRECATED: Complex merge logic replaced with simple timestamp-based sync
|
|
||||||
// These methods are no longer used but kept for reference
|
|
||||||
@available(*, deprecated, message: "Use simple timestamp-based sync instead")
|
|
||||||
private func performIntelligentMerge(local: ClimbDataBackup, server: ClimbDataBackup) throws
|
|
||||||
-> ClimbDataBackup
|
|
||||||
{
|
|
||||||
print("Merging data - preserving all entities to prevent data loss")
|
|
||||||
|
|
||||||
// Merge gyms by ID, keeping most recently updated
|
|
||||||
let mergedGyms = mergeGyms(local: local.gyms, server: server.gyms)
|
|
||||||
|
|
||||||
// Merge problems by ID, keeping most recently updated
|
|
||||||
let mergedProblems = mergeProblems(local: local.problems, server: server.problems)
|
|
||||||
|
|
||||||
// Merge sessions by ID, keeping most recently updated
|
|
||||||
let mergedSessions = mergeSessions(local: local.sessions, server: server.sessions)
|
|
||||||
|
|
||||||
// Merge attempts by ID, keeping most recently updated
|
|
||||||
let mergedAttempts = mergeAttempts(local: local.attempts, server: server.attempts)
|
|
||||||
|
|
||||||
print(
|
|
||||||
"Merge results: gyms=\(mergedGyms.count), problems=\(mergedProblems.count), sessions=\(mergedSessions.count), attempts=\(mergedAttempts.count)"
|
|
||||||
)
|
|
||||||
|
|
||||||
return ClimbDataBackup(
|
|
||||||
exportedAt: ISO8601DateFormatter().string(from: Date()),
|
|
||||||
version: "2.0",
|
|
||||||
formatVersion: "2.0",
|
|
||||||
gyms: mergedGyms,
|
|
||||||
problems: mergedProblems,
|
|
||||||
sessions: mergedSessions,
|
|
||||||
attempts: mergedAttempts
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
private func mergeGyms(local: [BackupGym], server: [BackupGym]) -> [BackupGym] {
|
|
||||||
var merged: [String: BackupGym] = [:]
|
|
||||||
|
|
||||||
// Add all local gyms
|
|
||||||
for gym in local {
|
|
||||||
merged[gym.id] = gym
|
|
||||||
}
|
|
||||||
|
|
||||||
// Add server gyms, replacing if newer
|
|
||||||
for serverGym in server {
|
|
||||||
if let localGym = merged[serverGym.id] {
|
|
||||||
// Keep the most recently updated
|
|
||||||
if isNewerThan(serverGym.updatedAt, localGym.updatedAt) {
|
|
||||||
merged[serverGym.id] = serverGym
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
// New gym from server
|
|
||||||
merged[serverGym.id] = serverGym
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return Array(merged.values)
|
|
||||||
}
|
|
||||||
|
|
||||||
private func mergeProblems(local: [BackupProblem], server: [BackupProblem]) -> [BackupProblem] {
|
|
||||||
var merged: [String: BackupProblem] = [:]
|
|
||||||
|
|
||||||
// Add all local problems
|
|
||||||
for problem in local {
|
|
||||||
merged[problem.id] = problem
|
|
||||||
}
|
|
||||||
|
|
||||||
// Add server problems, replacing if newer or merging image paths
|
|
||||||
for serverProblem in server {
|
|
||||||
if let localProblem = merged[serverProblem.id] {
|
|
||||||
// Merge image paths from both sources
|
|
||||||
let localImages = Set(localProblem.imagePaths ?? [])
|
|
||||||
let serverImages = Set(serverProblem.imagePaths ?? [])
|
|
||||||
let mergedImages = Array(localImages.union(serverImages))
|
|
||||||
|
|
||||||
// Use most recently updated problem data but with merged images
|
|
||||||
let newerProblem =
|
|
||||||
isNewerThan(serverProblem.updatedAt, localProblem.updatedAt)
|
|
||||||
? serverProblem : localProblem
|
|
||||||
merged[serverProblem.id] = BackupProblem(
|
|
||||||
id: newerProblem.id,
|
|
||||||
gymId: newerProblem.gymId,
|
|
||||||
name: newerProblem.name,
|
|
||||||
description: newerProblem.description,
|
|
||||||
climbType: newerProblem.climbType,
|
|
||||||
difficulty: newerProblem.difficulty,
|
|
||||||
tags: newerProblem.tags,
|
|
||||||
location: newerProblem.location,
|
|
||||||
imagePaths: mergedImages.isEmpty ? nil : mergedImages,
|
|
||||||
isActive: newerProblem.isActive,
|
|
||||||
dateSet: newerProblem.dateSet,
|
|
||||||
notes: newerProblem.notes,
|
|
||||||
createdAt: newerProblem.createdAt,
|
|
||||||
updatedAt: newerProblem.updatedAt
|
|
||||||
)
|
|
||||||
} else {
|
|
||||||
// New problem from server
|
|
||||||
merged[serverProblem.id] = serverProblem
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return Array(merged.values)
|
|
||||||
}
|
|
||||||
|
|
||||||
private func mergeSessions(local: [BackupClimbSession], server: [BackupClimbSession])
|
|
||||||
-> [BackupClimbSession]
|
|
||||||
{
|
|
||||||
var merged: [String: BackupClimbSession] = [:]
|
|
||||||
|
|
||||||
// Add all local sessions
|
|
||||||
for session in local {
|
|
||||||
merged[session.id] = session
|
|
||||||
}
|
|
||||||
|
|
||||||
// Add server sessions, replacing if newer
|
|
||||||
for serverSession in server {
|
|
||||||
if let localSession = merged[serverSession.id] {
|
|
||||||
// Keep the most recently updated
|
|
||||||
if isNewerThan(serverSession.updatedAt, localSession.updatedAt) {
|
|
||||||
merged[serverSession.id] = serverSession
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
// New session from server
|
|
||||||
merged[serverSession.id] = serverSession
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return Array(merged.values)
|
|
||||||
}
|
|
||||||
|
|
||||||
private func mergeAttempts(local: [BackupAttempt], server: [BackupAttempt]) -> [BackupAttempt] {
|
|
||||||
var merged: [String: BackupAttempt] = [:]
|
|
||||||
|
|
||||||
// Add all local attempts
|
|
||||||
for attempt in local {
|
|
||||||
merged[attempt.id] = attempt
|
|
||||||
}
|
|
||||||
|
|
||||||
// Add server attempts, replacing if newer
|
|
||||||
for serverAttempt in server {
|
|
||||||
if let localAttempt = merged[serverAttempt.id] {
|
|
||||||
// Keep the most recently created (attempts don't typically get updated)
|
|
||||||
if isNewerThan(serverAttempt.createdAt, localAttempt.createdAt) {
|
|
||||||
merged[serverAttempt.id] = serverAttempt
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
// New attempt from server
|
|
||||||
merged[serverAttempt.id] = serverAttempt
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return Array(merged.values)
|
|
||||||
}
|
|
||||||
|
|
||||||
private func isNewerThan(_ dateString1: String, _ dateString2: String) -> Bool {
|
|
||||||
let formatter = ISO8601DateFormatter()
|
|
||||||
guard let date1 = formatter.date(from: dateString1),
|
|
||||||
let date2 = formatter.date(from: dateString2)
|
|
||||||
else {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
return date1 > date2
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func disconnect() {
|
func disconnect() {
|
||||||
@@ -931,8 +779,6 @@ class SyncService: ObservableObject {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Removed SyncTrigger enum - now using simple auto sync on any data change
|
|
||||||
|
|
||||||
enum SyncError: LocalizedError {
|
enum SyncError: LocalizedError {
|
||||||
case notConfigured
|
case notConfigured
|
||||||
case notConnected
|
case notConnected
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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)")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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]
|
||||||
|
|||||||
@@ -32,6 +32,9 @@ class ClimbingDataManager: ObservableObject {
|
|||||||
// Sync service for automatic syncing
|
// Sync service for automatic syncing
|
||||||
let syncService = SyncService()
|
let syncService = SyncService()
|
||||||
|
|
||||||
|
// Published property to propagate sync state changes
|
||||||
|
@Published var isSyncing = false
|
||||||
|
|
||||||
private enum Keys {
|
private enum Keys {
|
||||||
static let gyms = "openclimb_gyms"
|
static let gyms = "openclimb_gyms"
|
||||||
static let problems = "openclimb_problems"
|
static let problems = "openclimb_problems"
|
||||||
@@ -67,6 +70,10 @@ class ClimbingDataManager: ObservableObject {
|
|||||||
migrateImagePaths()
|
migrateImagePaths()
|
||||||
setupLiveActivityNotifications()
|
setupLiveActivityNotifications()
|
||||||
|
|
||||||
|
// Keep our published isSyncing in sync with syncService.isSyncing
|
||||||
|
syncService.$isSyncing
|
||||||
|
.assign(to: &$isSyncing)
|
||||||
|
|
||||||
Task {
|
Task {
|
||||||
try? await Task.sleep(nanoseconds: 2_000_000_000)
|
try? await Task.sleep(nanoseconds: 2_000_000_000)
|
||||||
await performImageMaintenance()
|
await performImageMaintenance()
|
||||||
@@ -206,6 +213,9 @@ class ClimbingDataManager: ObservableObject {
|
|||||||
DataStateManager.shared.updateDataState()
|
DataStateManager.shared.updateDataState()
|
||||||
successMessage = "Gym added successfully"
|
successMessage = "Gym added successfully"
|
||||||
clearMessageAfterDelay()
|
clearMessageAfterDelay()
|
||||||
|
|
||||||
|
// Trigger auto-sync if enabled
|
||||||
|
syncService.triggerAutoSync(dataManager: self)
|
||||||
}
|
}
|
||||||
|
|
||||||
func updateGym(_ gym: Gym) {
|
func updateGym(_ gym: Gym) {
|
||||||
@@ -215,6 +225,9 @@ class ClimbingDataManager: ObservableObject {
|
|||||||
DataStateManager.shared.updateDataState()
|
DataStateManager.shared.updateDataState()
|
||||||
successMessage = "Gym updated successfully"
|
successMessage = "Gym updated successfully"
|
||||||
clearMessageAfterDelay()
|
clearMessageAfterDelay()
|
||||||
|
|
||||||
|
// Trigger auto-sync if enabled
|
||||||
|
syncService.triggerAutoSync(dataManager: self)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -237,6 +250,9 @@ class ClimbingDataManager: ObservableObject {
|
|||||||
DataStateManager.shared.updateDataState()
|
DataStateManager.shared.updateDataState()
|
||||||
successMessage = "Gym deleted successfully"
|
successMessage = "Gym deleted successfully"
|
||||||
clearMessageAfterDelay()
|
clearMessageAfterDelay()
|
||||||
|
|
||||||
|
// Trigger auto-sync if enabled
|
||||||
|
syncService.triggerAutoSync(dataManager: self)
|
||||||
}
|
}
|
||||||
|
|
||||||
func gym(withId id: UUID) -> Gym? {
|
func gym(withId id: UUID) -> Gym? {
|
||||||
@@ -261,6 +277,9 @@ class ClimbingDataManager: ObservableObject {
|
|||||||
DataStateManager.shared.updateDataState()
|
DataStateManager.shared.updateDataState()
|
||||||
successMessage = "Problem updated successfully"
|
successMessage = "Problem updated successfully"
|
||||||
clearMessageAfterDelay()
|
clearMessageAfterDelay()
|
||||||
|
|
||||||
|
// Trigger auto-sync if enabled
|
||||||
|
syncService.triggerAutoSync(dataManager: self)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -276,6 +295,9 @@ class ClimbingDataManager: ObservableObject {
|
|||||||
problems.removeAll { $0.id == problem.id }
|
problems.removeAll { $0.id == problem.id }
|
||||||
saveProblems()
|
saveProblems()
|
||||||
DataStateManager.shared.updateDataState()
|
DataStateManager.shared.updateDataState()
|
||||||
|
|
||||||
|
// Trigger auto-sync if enabled
|
||||||
|
syncService.triggerAutoSync(dataManager: self)
|
||||||
}
|
}
|
||||||
|
|
||||||
func problem(withId id: UUID) -> Problem? {
|
func problem(withId id: UUID) -> Problem? {
|
||||||
@@ -291,7 +313,7 @@ class ClimbingDataManager: ObservableObject {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func startSession(gymId: UUID, notes: String? = nil) {
|
func startSession(gymId: UUID, notes: String? = nil) {
|
||||||
|
// End any currently active session
|
||||||
if let currentActive = activeSession {
|
if let currentActive = activeSession {
|
||||||
endSession(currentActive.id)
|
endSession(currentActive.id)
|
||||||
}
|
}
|
||||||
@@ -314,6 +336,9 @@ class ClimbingDataManager: ObservableObject {
|
|||||||
for: newSession, gymName: gym.name)
|
for: newSession, gymName: gym.name)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Trigger auto-sync if enabled
|
||||||
|
syncService.triggerAutoSync(dataManager: self)
|
||||||
}
|
}
|
||||||
|
|
||||||
func endSession(_ sessionId: UUID) {
|
func endSession(_ sessionId: UUID) {
|
||||||
@@ -358,8 +383,11 @@ class ClimbingDataManager: ObservableObject {
|
|||||||
successMessage = "Session updated successfully"
|
successMessage = "Session updated successfully"
|
||||||
clearMessageAfterDelay()
|
clearMessageAfterDelay()
|
||||||
|
|
||||||
// Update Live Activity when session updates
|
// Update Live Activity when session is updated
|
||||||
updateLiveActivityForActiveSession()
|
updateLiveActivityForActiveSession()
|
||||||
|
|
||||||
|
// Trigger auto-sync if enabled
|
||||||
|
syncService.triggerAutoSync(dataManager: self)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -368,7 +396,7 @@ class ClimbingDataManager: ObservableObject {
|
|||||||
attempts.removeAll { $0.sessionId == session.id }
|
attempts.removeAll { $0.sessionId == session.id }
|
||||||
saveAttempts()
|
saveAttempts()
|
||||||
|
|
||||||
// Remove from active session if it's the current one
|
// If this is the active session, clear it
|
||||||
if activeSession?.id == session.id {
|
if activeSession?.id == session.id {
|
||||||
activeSession = nil
|
activeSession = nil
|
||||||
saveActiveSession()
|
saveActiveSession()
|
||||||
@@ -380,6 +408,12 @@ class ClimbingDataManager: ObservableObject {
|
|||||||
DataStateManager.shared.updateDataState()
|
DataStateManager.shared.updateDataState()
|
||||||
successMessage = "Session deleted successfully"
|
successMessage = "Session deleted successfully"
|
||||||
clearMessageAfterDelay()
|
clearMessageAfterDelay()
|
||||||
|
|
||||||
|
// Update Live Activity when session is deleted
|
||||||
|
updateLiveActivityForActiveSession()
|
||||||
|
|
||||||
|
// Trigger auto-sync if enabled
|
||||||
|
syncService.triggerAutoSync(dataManager: self)
|
||||||
}
|
}
|
||||||
|
|
||||||
func session(withId id: UUID) -> ClimbSession? {
|
func session(withId id: UUID) -> ClimbSession? {
|
||||||
@@ -421,6 +455,9 @@ class ClimbingDataManager: ObservableObject {
|
|||||||
|
|
||||||
// Update Live Activity when attempt is updated
|
// Update Live Activity when attempt is updated
|
||||||
updateLiveActivityForActiveSession()
|
updateLiveActivityForActiveSession()
|
||||||
|
|
||||||
|
// Trigger auto-sync if enabled
|
||||||
|
syncService.triggerAutoSync(dataManager: self)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -433,6 +470,9 @@ class ClimbingDataManager: ObservableObject {
|
|||||||
|
|
||||||
// Update Live Activity when attempt is deleted
|
// Update Live Activity when attempt is deleted
|
||||||
updateLiveActivityForActiveSession()
|
updateLiveActivityForActiveSession()
|
||||||
|
|
||||||
|
// Trigger auto-sync if enabled
|
||||||
|
syncService.triggerAutoSync(dataManager: self)
|
||||||
}
|
}
|
||||||
|
|
||||||
func attempts(forSession sessionId: UUID) -> [Attempt] {
|
func attempts(forSession sessionId: UUID) -> [Attempt] {
|
||||||
@@ -476,7 +516,7 @@ class ClimbingDataManager: ObservableObject {
|
|||||||
return gym(withId: mostUsedGymId)
|
return gym(withId: mostUsedGymId)
|
||||||
}
|
}
|
||||||
|
|
||||||
func resetAllData() {
|
func resetAllData(showSuccessMessage: Bool = true) {
|
||||||
gyms.removeAll()
|
gyms.removeAll()
|
||||||
problems.removeAll()
|
problems.removeAll()
|
||||||
sessions.removeAll()
|
sessions.removeAll()
|
||||||
@@ -490,9 +530,12 @@ class ClimbingDataManager: ObservableObject {
|
|||||||
userDefaults.removeObject(forKey: Keys.activeSession)
|
userDefaults.removeObject(forKey: Keys.activeSession)
|
||||||
|
|
||||||
DataStateManager.shared.reset()
|
DataStateManager.shared.reset()
|
||||||
|
|
||||||
|
if showSuccessMessage {
|
||||||
successMessage = "All data has been reset"
|
successMessage = "All data has been reset"
|
||||||
clearMessageAfterDelay()
|
clearMessageAfterDelay()
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func exportData() -> Data? {
|
func exportData() -> Data? {
|
||||||
do {
|
do {
|
||||||
@@ -511,26 +554,26 @@ 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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func importData(from data: Data) throws {
|
func importData(from data: Data, showSuccessMessage: Bool = true) throws {
|
||||||
do {
|
do {
|
||||||
let importResult = try ZipUtils.extractImportZip(data: data)
|
let importResult = try ZipUtils.extractImportZip(data: data)
|
||||||
|
|
||||||
@@ -566,7 +609,7 @@ class ClimbingDataManager: ObservableObject {
|
|||||||
|
|
||||||
try validateImportData(importData)
|
try validateImportData(importData)
|
||||||
|
|
||||||
resetAllData()
|
resetAllData(showSuccessMessage: showSuccessMessage)
|
||||||
|
|
||||||
let updatedProblems = updateProblemImagePaths(
|
let updatedProblems = updateProblemImagePaths(
|
||||||
problems: importData.problems,
|
problems: importData.problems,
|
||||||
@@ -586,9 +629,11 @@ class ClimbingDataManager: ObservableObject {
|
|||||||
// Update data state to current time since we just imported new data
|
// Update data state to current time since we just imported new data
|
||||||
DataStateManager.shared.updateDataState()
|
DataStateManager.shared.updateDataState()
|
||||||
|
|
||||||
|
if showSuccessMessage {
|
||||||
successMessage =
|
successMessage =
|
||||||
"Data imported successfully with \(importResult.imagePathMapping.count) images"
|
"Data imported successfully with \(importResult.imagePathMapping.count) images"
|
||||||
clearMessageAfterDelay()
|
clearMessageAfterDelay()
|
||||||
|
}
|
||||||
} catch {
|
} catch {
|
||||||
setError("Import failed: \(error.localizedDescription)")
|
setError("Import failed: \(error.localizedDescription)")
|
||||||
throw error
|
throw error
|
||||||
@@ -617,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)")
|
||||||
@@ -632,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)
|
||||||
}
|
}
|
||||||
@@ -643,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
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -703,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
|
||||||
}
|
}
|
||||||
@@ -741,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")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -758,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
|
||||||
@@ -766,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
|
||||||
@@ -782,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))
|
||||||
@@ -800,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
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -832,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()
|
||||||
@@ -849,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()
|
||||||
}
|
}
|
||||||
@@ -865,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()
|
||||||
}
|
}
|
||||||
@@ -894,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
|
||||||
@@ -934,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)")
|
||||||
@@ -958,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)")
|
||||||
|
|||||||
@@ -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")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -55,7 +55,7 @@ struct AddEditProblemView: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
NavigationView {
|
NavigationStack {
|
||||||
Form {
|
Form {
|
||||||
GymSelectionSection()
|
GymSelectionSection()
|
||||||
BasicInfoSection()
|
BasicInfoSection()
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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()
|
||||||
@@ -20,6 +20,27 @@ struct AnalyticsView: View {
|
|||||||
.padding()
|
.padding()
|
||||||
}
|
}
|
||||||
.navigationTitle("Analytics")
|
.navigationTitle("Analytics")
|
||||||
|
.toolbar {
|
||||||
|
ToolbarItem(placement: .navigationBarTrailing) {
|
||||||
|
if dataManager.isSyncing {
|
||||||
|
HStack(spacing: 2) {
|
||||||
|
ProgressView()
|
||||||
|
.progressViewStyle(CircularProgressViewStyle(tint: .blue))
|
||||||
|
.scaleEffect(0.6)
|
||||||
|
}
|
||||||
|
.padding(.horizontal, 6)
|
||||||
|
.padding(.vertical, 3)
|
||||||
|
.background(
|
||||||
|
Circle()
|
||||||
|
.fill(.regularMaterial)
|
||||||
|
)
|
||||||
|
.transition(.scale.combined(with: .opacity))
|
||||||
|
.animation(
|
||||||
|
.easeInOut(duration: 0.2), value: dataManager.isSyncing
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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])
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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()
|
||||||
@@ -15,7 +15,25 @@ struct GymsView: View {
|
|||||||
}
|
}
|
||||||
.navigationTitle("Gyms")
|
.navigationTitle("Gyms")
|
||||||
.toolbar {
|
.toolbar {
|
||||||
ToolbarItem(placement: .navigationBarTrailing) {
|
ToolbarItemGroup(placement: .navigationBarTrailing) {
|
||||||
|
if dataManager.isSyncing {
|
||||||
|
HStack(spacing: 2) {
|
||||||
|
ProgressView()
|
||||||
|
.progressViewStyle(CircularProgressViewStyle(tint: .blue))
|
||||||
|
.scaleEffect(0.6)
|
||||||
|
}
|
||||||
|
.padding(.horizontal, 6)
|
||||||
|
.padding(.vertical, 3)
|
||||||
|
.background(
|
||||||
|
Circle()
|
||||||
|
.fill(.regularMaterial)
|
||||||
|
)
|
||||||
|
.transition(.scale.combined(with: .opacity))
|
||||||
|
.animation(
|
||||||
|
.easeInOut(duration: 0.2), value: dataManager.isSyncing
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
Button("Add") {
|
Button("Add") {
|
||||||
showingAddGym = true
|
showingAddGym = true
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,9 +1,5 @@
|
|||||||
//
|
//
|
||||||
// LiveActivityDebugView.swift
|
// LiveActivityDebugView.swift
|
||||||
// OpenClimb
|
|
||||||
//
|
|
||||||
// Created by Assistant on 2025-09-15.
|
|
||||||
//
|
|
||||||
|
|
||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
|
||||||
@@ -13,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
|
||||||
@@ -91,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)
|
||||||
}
|
}
|
||||||
@@ -171,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
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -244,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")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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,10 +98,45 @@ struct ProblemsView: View {
|
|||||||
ProblemsList(problems: filteredProblems)
|
ProblemsList(problems: filteredProblems)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
.navigationTitle("Problems")
|
.navigationTitle("Problems")
|
||||||
.searchable(text: $searchText, prompt: "Search problems...")
|
.navigationBarTitleDisplayMode(.automatic)
|
||||||
.toolbar {
|
.toolbar {
|
||||||
ToolbarItem(placement: .navigationBarTrailing) {
|
ToolbarItemGroup(placement: .navigationBarTrailing) {
|
||||||
|
if dataManager.isSyncing {
|
||||||
|
HStack(spacing: 2) {
|
||||||
|
ProgressView()
|
||||||
|
.progressViewStyle(CircularProgressViewStyle(tint: .blue))
|
||||||
|
.scaleEffect(0.6)
|
||||||
|
}
|
||||||
|
.padding(.horizontal, 6)
|
||||||
|
.padding(.vertical, 3)
|
||||||
|
.background(
|
||||||
|
Circle()
|
||||||
|
.fill(.regularMaterial)
|
||||||
|
)
|
||||||
|
.transition(.scale.combined(with: .opacity))
|
||||||
|
.animation(
|
||||||
|
.easeInOut(duration: 0.2), value: dataManager.isSyncing
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
||||||
|
|||||||
@@ -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()
|
||||||
@@ -17,7 +17,25 @@ struct SessionsView: View {
|
|||||||
.navigationTitle("Sessions")
|
.navigationTitle("Sessions")
|
||||||
.navigationBarTitleDisplayMode(.automatic)
|
.navigationBarTitleDisplayMode(.automatic)
|
||||||
.toolbar {
|
.toolbar {
|
||||||
ToolbarItem(placement: .navigationBarTrailing) {
|
ToolbarItemGroup(placement: .navigationBarTrailing) {
|
||||||
|
if dataManager.isSyncing {
|
||||||
|
HStack(spacing: 2) {
|
||||||
|
ProgressView()
|
||||||
|
.progressViewStyle(CircularProgressViewStyle(tint: .blue))
|
||||||
|
.scaleEffect(0.6)
|
||||||
|
}
|
||||||
|
.padding(.horizontal, 6)
|
||||||
|
.padding(.vertical, 3)
|
||||||
|
.background(
|
||||||
|
Circle()
|
||||||
|
.fill(.regularMaterial)
|
||||||
|
)
|
||||||
|
.transition(.scale.combined(with: .opacity))
|
||||||
|
.animation(
|
||||||
|
.easeInOut(duration: 0.2), value: dataManager.isSyncing
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
if dataManager.gyms.isEmpty {
|
if dataManager.gyms.isEmpty {
|
||||||
EmptyView()
|
EmptyView()
|
||||||
} else if dataManager.activeSession == nil {
|
} else if dataManager.activeSession == nil {
|
||||||
@@ -35,7 +53,6 @@ struct SessionsView: View {
|
|||||||
AddEditSessionView()
|
AddEditSessionView()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.navigationViewStyle(.stack)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -111,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) {
|
||||||
@@ -133,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)
|
||||||
@@ -162,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 {
|
||||||
|
|||||||
@@ -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,28 @@ struct SettingsView: View {
|
|||||||
AppInfoSection()
|
AppInfoSection()
|
||||||
}
|
}
|
||||||
.navigationTitle("Settings")
|
.navigationTitle("Settings")
|
||||||
|
.navigationBarTitleDisplayMode(.automatic)
|
||||||
|
.toolbar {
|
||||||
|
ToolbarItem(placement: .navigationBarTrailing) {
|
||||||
|
if dataManager.isSyncing {
|
||||||
|
HStack(spacing: 2) {
|
||||||
|
ProgressView()
|
||||||
|
.progressViewStyle(CircularProgressViewStyle(tint: .blue))
|
||||||
|
.scaleEffect(0.6)
|
||||||
|
}
|
||||||
|
.padding(.horizontal, 6)
|
||||||
|
.padding(.vertical, 3)
|
||||||
|
.background(
|
||||||
|
Circle()
|
||||||
|
.fill(.regularMaterial)
|
||||||
|
)
|
||||||
|
.transition(.scale.combined(with: .opacity))
|
||||||
|
.animation(
|
||||||
|
.easeInOut(duration: 0.2), value: dataManager.isSyncing
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
.sheet(
|
.sheet(
|
||||||
item: Binding<SheetType?>(
|
item: Binding<SheetType?>(
|
||||||
get: { activeSheet },
|
get: { activeSheet },
|
||||||
@@ -37,6 +60,7 @@ struct SettingsView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
extension SheetType: Identifiable {
|
extension SheetType: Identifiable {
|
||||||
var id: String {
|
var id: String {
|
||||||
@@ -170,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
|
||||||
@@ -436,6 +460,7 @@ struct SyncSection: View {
|
|||||||
.foregroundColor(.red)
|
.foregroundColor(.red)
|
||||||
.padding(.leading, 24)
|
.padding(.leading, 24)
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.sheet(isPresented: $showingSyncSettings) {
|
.sheet(isPresented: $showingSyncSettings) {
|
||||||
@@ -476,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)
|
||||||
@@ -669,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))
|
||||||
@@ -683,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)
|
||||||
|
|||||||
255
ios/OpenClimbTests/OpenClimbTests.swift
Normal file
255
ios/OpenClimbTests/OpenClimbTests.swift
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,12 +1,8 @@
|
|||||||
//
|
//
|
||||||
// AppIntent.swift
|
// AppIntent.swift
|
||||||
// SessionStatusLive
|
|
||||||
//
|
|
||||||
// Created by Atridad Lahiji on 2025-09-15.
|
|
||||||
//
|
|
||||||
|
|
||||||
import WidgetKit
|
|
||||||
import AppIntents
|
import AppIntents
|
||||||
|
import WidgetKit
|
||||||
|
|
||||||
struct ConfigurationAppIntent: WidgetConfigurationIntent {
|
struct ConfigurationAppIntent: WidgetConfigurationIntent {
|
||||||
static var title: LocalizedStringResource { "Configuration" }
|
static var title: LocalizedStringResource { "Configuration" }
|
||||||
|
|||||||
@@ -1,9 +1,5 @@
|
|||||||
//
|
//
|
||||||
// SessionStatusLive.swift
|
// SessionStatusLive.swift
|
||||||
// SessionStatusLive
|
|
||||||
//
|
|
||||||
// Created by Atridad Lahiji on 2025-09-15.
|
|
||||||
//
|
|
||||||
|
|
||||||
import SwiftUI
|
import SwiftUI
|
||||||
import WidgetKit
|
import WidgetKit
|
||||||
|
|||||||
@@ -1,12 +1,8 @@
|
|||||||
//
|
//
|
||||||
// SessionStatusLiveBundle.swift
|
// SessionStatusLiveBundle.swift
|
||||||
// SessionStatusLive
|
|
||||||
//
|
|
||||||
// Created by Atridad Lahiji on 2025-09-15.
|
|
||||||
//
|
|
||||||
|
|
||||||
import WidgetKit
|
|
||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
import WidgetKit
|
||||||
|
|
||||||
@main
|
@main
|
||||||
struct SessionStatusLiveBundle: WidgetBundle {
|
struct SessionStatusLiveBundle: WidgetBundle {
|
||||||
|
|||||||
@@ -1,9 +1,5 @@
|
|||||||
//
|
//
|
||||||
// SessionStatusLiveControl.swift
|
// SessionStatusLiveControl.swift
|
||||||
// SessionStatusLive
|
|
||||||
//
|
|
||||||
// Created by Atridad Lahiji on 2025-09-15.
|
|
||||||
//
|
|
||||||
|
|
||||||
import AppIntents
|
import AppIntents
|
||||||
import SwiftUI
|
import SwiftUI
|
||||||
@@ -43,7 +39,8 @@ extension SessionStatusLiveControl {
|
|||||||
|
|
||||||
func currentValue(configuration: TimerConfiguration) async throws -> Value {
|
func currentValue(configuration: TimerConfiguration) async throws -> Value {
|
||||||
let isRunning = true // Check if the timer is running
|
let isRunning = true // Check if the timer is running
|
||||||
return SessionStatusLiveControl.Value(isRunning: isRunning, name: configuration.timerName)
|
return SessionStatusLiveControl.Value(
|
||||||
|
isRunning: isRunning, name: configuration.timerName)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,9 +1,5 @@
|
|||||||
//
|
//
|
||||||
// SessionStatusLiveLiveActivity.swift
|
// SessionStatusLiveLiveActivity.swift
|
||||||
// SessionStatusLive
|
|
||||||
//
|
|
||||||
// Created by Atridad Lahiji on 2025-09-15.
|
|
||||||
//
|
|
||||||
|
|
||||||
import ActivityKit
|
import ActivityKit
|
||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
|||||||
@@ -1,303 +0,0 @@
|
|||||||
# OpenClimb Sync Server Deployment Guide
|
|
||||||
|
|
||||||
This guide covers deploying the OpenClimb Sync Server using the automated Docker build and deployment system.
|
|
||||||
|
|
||||||
## Overview
|
|
||||||
|
|
||||||
The sync server is automatically built into a Docker container via GitHub Actions and can be deployed to any Docker-compatible environment.
|
|
||||||
|
|
||||||
## Prerequisites
|
|
||||||
|
|
||||||
- Docker and Docker Compose installed
|
|
||||||
- Access to the container registry (configured in GitHub secrets)
|
|
||||||
- Basic understanding of Docker deployments
|
|
||||||
|
|
||||||
## Quick Start
|
|
||||||
|
|
||||||
### 1. Automated Deployment (Recommended)
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Clone the repository
|
|
||||||
git clone <your-repo-url>
|
|
||||||
cd OpenClimb/sync-server
|
|
||||||
|
|
||||||
# Run the deployment script
|
|
||||||
./deploy.sh
|
|
||||||
```
|
|
||||||
|
|
||||||
The script will:
|
|
||||||
- Create necessary directories
|
|
||||||
- Pull the latest container image
|
|
||||||
- Stop any existing containers
|
|
||||||
- Start the new container
|
|
||||||
- Verify deployment success
|
|
||||||
|
|
||||||
### 2. Manual Deployment
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Pull the latest image
|
|
||||||
docker pull your-registry.com/username/openclimb-sync-server:latest
|
|
||||||
|
|
||||||
# Create environment file
|
|
||||||
cp .env.example .env.prod
|
|
||||||
# Edit .env.prod with your configuration
|
|
||||||
|
|
||||||
# Deploy with docker-compose
|
|
||||||
docker-compose -f docker-compose.prod.yml up -d
|
|
||||||
```
|
|
||||||
|
|
||||||
## Configuration
|
|
||||||
|
|
||||||
### Environment Variables
|
|
||||||
|
|
||||||
Create a `.env.prod` file with the following variables:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Container registry settings
|
|
||||||
REPO_HOST=your-registry.example.com
|
|
||||||
REPO_OWNER=your-username
|
|
||||||
|
|
||||||
# Server configuration
|
|
||||||
AUTH_TOKEN=your-secure-auth-token-here-make-it-long-and-random
|
|
||||||
PORT=8080
|
|
||||||
|
|
||||||
# Optional: Custom domain (for Traefik)
|
|
||||||
TRAEFIK_HOST=sync.openclimb.example.com
|
|
||||||
```
|
|
||||||
|
|
||||||
### Required Secrets (GitHub)
|
|
||||||
|
|
||||||
Configure these secrets in your GitHub repository settings:
|
|
||||||
|
|
||||||
- `REPO_HOST`: Your container registry hostname
|
|
||||||
- `DEPLOY_TOKEN`: Authentication token for the registry
|
|
||||||
|
|
||||||
## Container Build Process
|
|
||||||
|
|
||||||
The GitHub Action (`sync-server-deploy.yml`) automatically:
|
|
||||||
|
|
||||||
1. **Triggers on:**
|
|
||||||
- Push to `main` branch (when sync-server files change)
|
|
||||||
- Pull requests to `main` branch
|
|
||||||
|
|
||||||
2. **Build Process:**
|
|
||||||
- Uses multi-stage Docker build
|
|
||||||
- Compiles Go binary in builder stage
|
|
||||||
- Creates minimal Alpine-based runtime image
|
|
||||||
- Pushes to container registry with tags:
|
|
||||||
- `latest` (always points to newest)
|
|
||||||
- `<commit-sha>` (specific version)
|
|
||||||
|
|
||||||
3. **Caching:**
|
|
||||||
- Uses GitHub Actions cache for faster builds
|
|
||||||
- Incremental builds when possible
|
|
||||||
|
|
||||||
## Deployment Options
|
|
||||||
|
|
||||||
### Option 1: Simple Docker Run
|
|
||||||
```bash
|
|
||||||
docker run -d \
|
|
||||||
--name openclimb-sync-server \
|
|
||||||
-p 8080:8080 \
|
|
||||||
-v $(pwd)/data:/root/data \
|
|
||||||
-e AUTH_TOKEN=your-token-here \
|
|
||||||
your-registry.com/username/openclimb-sync-server:latest
|
|
||||||
```
|
|
||||||
|
|
||||||
### Option 2: Docker Compose (Recommended)
|
|
||||||
```bash
|
|
||||||
docker-compose -f docker-compose.prod.yml up -d
|
|
||||||
```
|
|
||||||
|
|
||||||
### Option 3: Kubernetes
|
|
||||||
```yaml
|
|
||||||
apiVersion: apps/v1
|
|
||||||
kind: Deployment
|
|
||||||
metadata:
|
|
||||||
name: openclimb-sync-server
|
|
||||||
spec:
|
|
||||||
replicas: 1
|
|
||||||
selector:
|
|
||||||
matchLabels:
|
|
||||||
app: openclimb-sync-server
|
|
||||||
template:
|
|
||||||
metadata:
|
|
||||||
labels:
|
|
||||||
app: openclimb-sync-server
|
|
||||||
spec:
|
|
||||||
containers:
|
|
||||||
- name: sync-server
|
|
||||||
image: your-registry.com/username/openclimb-sync-server:latest
|
|
||||||
ports:
|
|
||||||
- containerPort: 8080
|
|
||||||
env:
|
|
||||||
- name: AUTH_TOKEN
|
|
||||||
valueFrom:
|
|
||||||
secretKeyRef:
|
|
||||||
name: openclimb-secrets
|
|
||||||
key: auth-token
|
|
||||||
volumeMounts:
|
|
||||||
- name: data-volume
|
|
||||||
mountPath: /root/data
|
|
||||||
volumes:
|
|
||||||
- name: data-volume
|
|
||||||
persistentVolumeClaim:
|
|
||||||
claimName: openclimb-data
|
|
||||||
```
|
|
||||||
|
|
||||||
## Data Persistence
|
|
||||||
|
|
||||||
The sync server stores data in `/root/data` inside the container. **Always mount a volume** to preserve data:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Local directory mounting
|
|
||||||
-v $(pwd)/data:/root/data
|
|
||||||
|
|
||||||
# Named volume (recommended for production)
|
|
||||||
-v openclimb-data:/root/data
|
|
||||||
```
|
|
||||||
|
|
||||||
### Data Structure
|
|
||||||
```
|
|
||||||
data/
|
|
||||||
├── climb_data.json # Main sync data
|
|
||||||
├── images/ # Uploaded images
|
|
||||||
│ ├── problem_*.jpg
|
|
||||||
│ └── ...
|
|
||||||
└── logs/ # Server logs (optional)
|
|
||||||
```
|
|
||||||
|
|
||||||
## Monitoring and Maintenance
|
|
||||||
|
|
||||||
### Health Check
|
|
||||||
```bash
|
|
||||||
curl http://localhost:8080/health
|
|
||||||
```
|
|
||||||
|
|
||||||
### View Logs
|
|
||||||
```bash
|
|
||||||
# Docker Compose
|
|
||||||
docker-compose -f docker-compose.prod.yml logs -f
|
|
||||||
|
|
||||||
# Direct Docker
|
|
||||||
docker logs -f openclimb-sync-server
|
|
||||||
```
|
|
||||||
|
|
||||||
### Update to Latest Version
|
|
||||||
```bash
|
|
||||||
# Using deploy script
|
|
||||||
./deploy.sh
|
|
||||||
|
|
||||||
# Manual update
|
|
||||||
docker-compose -f docker-compose.prod.yml pull
|
|
||||||
docker-compose -f docker-compose.prod.yml up -d
|
|
||||||
```
|
|
||||||
|
|
||||||
## Reverse Proxy Setup (Optional)
|
|
||||||
|
|
||||||
### Nginx
|
|
||||||
```nginx
|
|
||||||
server {
|
|
||||||
listen 80;
|
|
||||||
server_name sync.openclimb.example.com;
|
|
||||||
|
|
||||||
location / {
|
|
||||||
proxy_pass http://localhost:8080;
|
|
||||||
proxy_set_header Host $host;
|
|
||||||
proxy_set_header X-Real-IP $remote_addr;
|
|
||||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
|
||||||
proxy_set_header X-Forwarded-Proto $scheme;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### Traefik (Labels included in docker-compose.prod.yml)
|
|
||||||
```yaml
|
|
||||||
labels:
|
|
||||||
- "traefik.enable=true"
|
|
||||||
- "traefik.http.routers.openclimb-sync.rule=Host(`sync.openclimb.example.com`)"
|
|
||||||
- "traefik.http.routers.openclimb-sync.tls.certresolver=letsencrypt"
|
|
||||||
```
|
|
||||||
|
|
||||||
## Security Considerations
|
|
||||||
|
|
||||||
1. **AUTH_TOKEN**: Use a long, random token (32+ characters)
|
|
||||||
2. **HTTPS**: Always use HTTPS in production (via reverse proxy)
|
|
||||||
3. **Firewall**: Only expose port 8080 to your reverse proxy, not publicly
|
|
||||||
4. **Updates**: Regularly update to the latest container image
|
|
||||||
5. **Backups**: Regularly backup the `data/` directory
|
|
||||||
|
|
||||||
## Troubleshooting
|
|
||||||
|
|
||||||
### Container Won't Start
|
|
||||||
```bash
|
|
||||||
# Check logs
|
|
||||||
docker logs openclimb-sync-server
|
|
||||||
|
|
||||||
# Common issues:
|
|
||||||
# - Missing AUTH_TOKEN environment variable
|
|
||||||
# - Port 8080 already in use
|
|
||||||
# - Insufficient permissions on data directory
|
|
||||||
```
|
|
||||||
|
|
||||||
### Sync Fails from Mobile Apps
|
|
||||||
```bash
|
|
||||||
# Verify server is accessible
|
|
||||||
curl -H "Authorization: Bearer your-token" http://your-server:8080/sync
|
|
||||||
|
|
||||||
# Check server logs for authentication errors
|
|
||||||
docker logs openclimb-sync-server | grep "401\|403"
|
|
||||||
```
|
|
||||||
|
|
||||||
### Image Upload Issues
|
|
||||||
```bash
|
|
||||||
# Check disk space
|
|
||||||
df -h
|
|
||||||
|
|
||||||
# Verify data directory permissions
|
|
||||||
ls -la data/
|
|
||||||
```
|
|
||||||
|
|
||||||
## Performance Tuning
|
|
||||||
|
|
||||||
For high-load deployments:
|
|
||||||
|
|
||||||
```yaml
|
|
||||||
# docker-compose.prod.yml
|
|
||||||
services:
|
|
||||||
openclimb-sync-server:
|
|
||||||
deploy:
|
|
||||||
resources:
|
|
||||||
limits:
|
|
||||||
memory: 512M
|
|
||||||
cpus: '0.5'
|
|
||||||
reservations:
|
|
||||||
memory: 256M
|
|
||||||
cpus: '0.25'
|
|
||||||
```
|
|
||||||
|
|
||||||
## Backup Strategy
|
|
||||||
|
|
||||||
```bash
|
|
||||||
#!/bin/bash
|
|
||||||
# backup.sh - Run daily via cron
|
|
||||||
|
|
||||||
DATE=$(date +%Y%m%d_%H%M%S)
|
|
||||||
BACKUP_DIR="/backups/openclimb"
|
|
||||||
|
|
||||||
# Create backup directory
|
|
||||||
mkdir -p "$BACKUP_DIR"
|
|
||||||
|
|
||||||
# Backup data directory
|
|
||||||
tar -czf "$BACKUP_DIR/openclimb_data_$DATE.tar.gz" \
|
|
||||||
-C /path/to/sync-server data/
|
|
||||||
|
|
||||||
# Keep only last 30 days
|
|
||||||
find "$BACKUP_DIR" -name "openclimb_data_*.tar.gz" -mtime +30 -delete
|
|
||||||
```
|
|
||||||
|
|
||||||
## Support
|
|
||||||
|
|
||||||
- **Issues**: Create an issue in the GitHub repository
|
|
||||||
- **Documentation**: Check the main OpenClimb README
|
|
||||||
- **Logs**: Always
|
|
||||||
@@ -1,14 +1,9 @@
|
|||||||
# OpenClimb Sync Server Configuration
|
# Required
|
||||||
|
|
||||||
# Required: Secret token for authentication
|
|
||||||
# Generate a secure random token and share it between your apps and server
|
|
||||||
AUTH_TOKEN=your-secure-secret-token-here
|
AUTH_TOKEN=your-secure-secret-token-here
|
||||||
|
IMAGE="git.atri.dad/atridad/openclimb-sync:latest"
|
||||||
|
APP_PORT=1337
|
||||||
|
ROOT_DIR="./data"
|
||||||
|
|
||||||
# Optional: Port to run the server on (default: 8080)
|
# Optional
|
||||||
PORT=8080
|
DATA_FILE=/data/data.json
|
||||||
|
IMAGES_DIR=/data/images
|
||||||
# Optional: Path to store the sync data (default: ./data/climb_data.json)
|
|
||||||
DATA_FILE=./data/climb_data.json
|
|
||||||
|
|
||||||
# Optional: Directory to store images (default: ./data/images)
|
|
||||||
IMAGES_DIR=./data/images
|
|
||||||
|
|||||||
@@ -2,11 +2,12 @@ services:
|
|||||||
openclimb-sync:
|
openclimb-sync:
|
||||||
image: ${IMAGE}
|
image: ${IMAGE}
|
||||||
ports:
|
ports:
|
||||||
- "8080:8080"
|
- ${APP_PORT}:8080
|
||||||
environment:
|
environment:
|
||||||
- AUTH_TOKEN=${AUTH_TOKEN:-your-secret-token-here}
|
- AUTH_TOKEN=${AUTH_TOKEN}
|
||||||
- DATA_FILE=/data/climb_data.json
|
- DATA_FILE=${DATA_FILE}
|
||||||
- IMAGES_DIR=/data/images
|
- IMAGES_DIR=${IMAGES_DIR}
|
||||||
volumes:
|
volumes:
|
||||||
- ./data:/data
|
- ${ROOT_DIR}:/data
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
|
networks: {}
|
||||||
|
|||||||
479
sync/format_test.go
Normal file
479
sync/format_test.go
Normal 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]
|
||||||
|
}
|
||||||
@@ -1,3 +1,3 @@
|
|||||||
module openclimb-sync
|
module openclimb-sync
|
||||||
|
|
||||||
go 1.25
|
go 1.21
|
||||||
|
|||||||
361
sync/main_test.go
Normal file
361
sync/main_test.go
Normal 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)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
31
sync/run.sh
31
sync/run.sh
@@ -1,31 +0,0 @@
|
|||||||
#!/bin/bash
|
|
||||||
# OpenClimb Sync Server Runner
|
|
||||||
set -e
|
|
||||||
|
|
||||||
# Default values
|
|
||||||
AUTH_TOKEN=${AUTH_TOKEN:-}
|
|
||||||
PORT=${PORT:-8080}
|
|
||||||
DATA_FILE=${DATA_FILE:-./data/climb_data.json}
|
|
||||||
|
|
||||||
# Check if AUTH_TOKEN is set
|
|
||||||
if [ -z "$AUTH_TOKEN" ]; then
|
|
||||||
echo "Error: AUTH_TOKEN environment variable must be set"
|
|
||||||
echo "Usage: AUTH_TOKEN=your-secret-token ./run.sh"
|
|
||||||
echo "Or: export AUTH_TOKEN=your-secret-token && ./run.sh"
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Create data directory if it doesn't exist
|
|
||||||
mkdir -p "$(dirname "$DATA_FILE")"
|
|
||||||
|
|
||||||
# Build and run
|
|
||||||
echo "Building OpenClimb sync server..."
|
|
||||||
go build -o sync-server .
|
|
||||||
|
|
||||||
echo "Starting server on port $PORT"
|
|
||||||
echo "Data will be stored in: $DATA_FILE"
|
|
||||||
echo "Images will be stored in: ${IMAGES_DIR:-./data/images}"
|
|
||||||
echo "Use Authorization: Bearer $AUTH_TOKEN in your requests"
|
|
||||||
echo ""
|
|
||||||
|
|
||||||
exec ./sync-server
|
|
||||||
Reference in New Issue
Block a user