[Android] 1.9.2
All checks were successful
OpenClimb Docker Deploy / build-and-push (push) Successful in 2m29s

This commit is contained in:
2025-10-12 20:41:39 -06:00
parent 405fb06d5d
commit 30d2b3938e
23 changed files with 620 additions and 1721 deletions

22
android/README.md Normal file
View File

@@ -0,0 +1,22 @@
# OpenClimb for Android
This is the native Android app for OpenClimb, built with Kotlin and Jetpack Compose.
## Project Structure
This is a standard Android Gradle project. The main code lives in `app/src/main/java/com/atridad/openclimb/`.
- `data/`: Handles all the app's data.
- `database/`: Room database setup (DAOs, entities).
- `model/`: Core data models (`Problem`, `Gym`, `ClimbSession`).
- `repository/`: Manages the data, providing a clean API for the rest of the app.
- `sync/`: Handles talking to the sync server.
- `ui/`: All the Jetpack Compose UI code.
- `screens/`: The main screens of the app.
- `components/`: Reusable UI bits used across screens.
- `viewmodel/`: `ClimbViewModel` for managing UI state.
- `navigation/`: Navigation graph and routes using Jetpack Navigation.
- `service/`: Background service for tracking climbing sessions.
- `utils/`: Helpers for things like date formatting and image handling.
The app is built to be offline-first. All data is stored locally on your device and works without an internet connection.

View File

@@ -16,8 +16,8 @@ android {
applicationId = "com.atridad.openclimb" applicationId = "com.atridad.openclimb"
minSdk = 31 minSdk = 31
targetSdk = 36 targetSdk = 36
versionCode = 38 versionCode = 39
versionName = "1.9.1" versionName = "1.9.2"
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
} }

View File

@@ -10,6 +10,7 @@
<!-- Permission for sync functionality --> <!-- Permission for sync functionality -->
<uses-permission android:name="android.permission.INTERNET" /> <uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<!-- Health Connect permissions --> <!-- Health Connect permissions -->
<uses-permission android:name="android.permission.health.READ_EXERCISE" /> <uses-permission android:name="android.permission.health.READ_EXERCISE" />

View File

@@ -1,205 +0,0 @@
package com.atridad.openclimb.data.migration
import android.content.Context
import android.util.Log
import com.atridad.openclimb.data.repository.ClimbRepository
import com.atridad.openclimb.utils.ImageNamingUtils
import com.atridad.openclimb.utils.ImageUtils
import kotlinx.coroutines.flow.first
/**
* Service responsible for migrating images to use consistent naming convention across platforms.
* This ensures that iOS and Android use the same image filenames for sync compatibility.
*/
class ImageMigrationService(private val context: Context, private val repository: ClimbRepository) {
companion object {
private const val TAG = "ImageMigrationService"
private const val MIGRATION_PREF_KEY = "image_naming_migration_completed"
}
/**
* Performs a complete migration of all images in the system to use consistent naming. This
* should be called once during app startup after the naming convention is implemented.
*/
suspend fun performFullMigration(): ImageMigrationResult {
Log.i(TAG, "Starting full image naming migration")
val prefs = context.getSharedPreferences("openclimb_migration", Context.MODE_PRIVATE)
if (prefs.getBoolean(MIGRATION_PREF_KEY, false)) {
Log.i(TAG, "Image migration already completed, skipping")
return ImageMigrationResult.AlreadyCompleted
}
try {
val allProblems = repository.getAllProblems().first()
val migrationResults = mutableMapOf<String, String>()
var migratedCount = 0
var errorCount = 0
Log.i(TAG, "Found ${allProblems.size} problems to check for image migration")
for (problem in allProblems) {
if (problem.imagePaths.isNotEmpty()) {
Log.d(
TAG,
"Migrating images for problem '${problem.name}': ${problem.imagePaths}"
)
try {
val problemMigrations =
ImageUtils.migrateImageNaming(
context = context,
problemId = problem.id,
currentImagePaths = problem.imagePaths
)
if (problemMigrations.isNotEmpty()) {
migrationResults.putAll(problemMigrations)
migratedCount += problemMigrations.size
// Update image paths
val newImagePaths =
problem.imagePaths.map { oldPath ->
problemMigrations[oldPath] ?: oldPath
}
val updatedProblem = problem.copy(imagePaths = newImagePaths)
repository.insertProblem(updatedProblem)
Log.d(
TAG,
"Updated problem '${problem.name}' with ${problemMigrations.size} migrated images"
)
}
} catch (e: Exception) {
Log.e(
TAG,
"Failed to migrate images for problem '${problem.name}': ${e.message}",
e
)
errorCount++
}
}
}
// Mark migration as completed
prefs.edit().putBoolean(MIGRATION_PREF_KEY, true).apply()
Log.i(
TAG,
"Image migration completed: $migratedCount images migrated, $errorCount errors"
)
return ImageMigrationResult.Success(
totalMigrated = migratedCount,
errors = errorCount,
migrations = migrationResults
)
} catch (e: Exception) {
Log.e(TAG, "Image migration failed: ${e.message}", e)
return ImageMigrationResult.Failed(e.message ?: "Unknown error")
}
}
/** Validates that all images in the system follow the consistent naming convention. */
suspend fun validateImageNaming(): ValidationResult {
try {
val allProblems = repository.getAllProblems().first()
val validImages = mutableListOf<String>()
val invalidImages = mutableListOf<String>()
val missingImages = mutableListOf<String>()
for (problem in allProblems) {
for (imagePath in problem.imagePaths) {
val filename = imagePath.substringAfterLast('/')
// Check if file exists
val imageFile = ImageUtils.getImageFile(context, imagePath)
if (!imageFile.exists()) {
missingImages.add(imagePath)
continue
}
// Check if filename follows convention
if (ImageNamingUtils.isValidImageFilename(filename)) {
validImages.add(imagePath)
} else {
invalidImages.add(imagePath)
}
}
}
return ValidationResult(
totalImages = validImages.size + invalidImages.size + missingImages.size,
validImages = validImages,
invalidImages = invalidImages,
missingImages = missingImages
)
} catch (e: Exception) {
Log.e(TAG, "Image validation failed: ${e.message}", e)
return ValidationResult(
totalImages = 0,
validImages = emptyList(),
invalidImages = emptyList(),
missingImages = emptyList()
)
}
}
/** Migrates images for a specific problem during sync operations. */
suspend fun migrateProblemImages(
problemId: String,
currentImagePaths: List<String>
): Map<String, String> {
return try {
ImageUtils.migrateImageNaming(context, problemId, currentImagePaths)
} catch (e: Exception) {
Log.e(TAG, "Failed to migrate images for problem $problemId: ${e.message}", e)
emptyMap()
}
}
/**
* Cleans up any orphaned image files that don't follow our naming convention and aren't
* referenced by any problems.
*/
suspend fun cleanupOrphanedImages() {
try {
val allProblems = repository.getAllProblems().first()
val referencedPaths = allProblems.flatMap { it.imagePaths }.toSet()
ImageUtils.cleanupOrphanedImages(context, referencedPaths)
Log.i(TAG, "Orphaned image cleanup completed")
} catch (e: Exception) {
Log.e(TAG, "Failed to cleanup orphaned images: ${e.message}", e)
}
}
}
/** Result of an image migration operation */
sealed class ImageMigrationResult {
object AlreadyCompleted : ImageMigrationResult()
data class Success(
val totalMigrated: Int,
val errors: Int,
val migrations: Map<String, String>
) : ImageMigrationResult()
data class Failed(val error: String) : ImageMigrationResult()
}
/** Result of image naming validation */
data class ValidationResult(
val totalImages: Int,
val validImages: List<String>,
val invalidImages: List<String>,
val missingImages: List<String>
) {
val isAllValid: Boolean
get() = invalidImages.isEmpty() && missingImages.isEmpty()
val validPercentage: Double
get() = if (totalImages == 0) 100.0 else (validImages.size.toDouble() / totalImages) * 100
}

View File

@@ -75,25 +75,4 @@ data class Attempt(
} }
} }
fun updated(
result: AttemptResult? = null,
highestHold: String? = null,
notes: String? = null,
duration: Long? = null,
restTime: Long? = null
): Attempt {
return Attempt(
id = this.id,
sessionId = this.sessionId,
problemId = this.problemId,
result = result ?: this.result,
highestHold = highestHold ?: this.highestHold,
notes = notes ?: this.notes,
duration = duration ?: this.duration,
restTime = restTime ?: this.restTime,
timestamp = this.timestamp,
createdAt = this.createdAt,
updatedAt = DateFormatUtils.nowISO8601()
)
}
} }

View File

@@ -207,7 +207,7 @@ 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 {
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") return 0
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

View File

@@ -17,8 +17,6 @@ import com.atridad.openclimb.utils.ZipExportImportUtils
import java.io.File import java.io.File
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.first
import kotlinx.serialization.decodeFromString
import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json import kotlinx.serialization.json.Json
class ClimbRepository(database: OpenClimbDatabase, private val context: Context) { class ClimbRepository(database: OpenClimbDatabase, private val context: Context) {
@@ -288,7 +286,7 @@ class ClimbRepository(database: OpenClimbDatabase, private val context: Context)
try { try {
val deletion = json.decodeFromString<DeletedItem>(value) val deletion = json.decodeFromString<DeletedItem>(value)
deletions.add(deletion) deletions.add(deletion)
} catch (e: Exception) { } catch (_: Exception) {
// Invalid deletion record, ignore // Invalid deletion record, ignore
} }
} }

View File

@@ -4,6 +4,7 @@ import android.content.Context
import android.content.SharedPreferences import android.content.SharedPreferences
import android.util.Log import android.util.Log
import com.atridad.openclimb.utils.DateFormatUtils import com.atridad.openclimb.utils.DateFormatUtils
import androidx.core.content.edit
/** /**
* 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. This tracks when any data in the
@@ -35,7 +36,7 @@ class DataStateManager(context: Context) {
*/ */
fun updateDataState() { fun updateDataState() {
val now = DateFormatUtils.nowISO8601() val now = DateFormatUtils.nowISO8601()
prefs.edit().putString(KEY_LAST_MODIFIED, now).apply() prefs.edit { putString(KEY_LAST_MODIFIED, now) }
Log.d(TAG, "Data state updated to: $now") Log.d(TAG, "Data state updated to: $now")
} }
@@ -48,21 +49,6 @@ class DataStateManager(context: Context) {
?: DateFormatUtils.nowISO8601() ?: DateFormatUtils.nowISO8601()
} }
/**
* Sets the data state timestamp to a specific value. Used when importing data from server to
* sync the state.
*/
fun setLastModified(timestamp: String) {
prefs.edit().putString(KEY_LAST_MODIFIED, timestamp).apply()
Log.d(TAG, "Data state set to: $timestamp")
}
/** Resets the data state (for testing or complete data wipe). */
fun reset() {
prefs.edit().clear().apply()
Log.d(TAG, "Data state reset")
}
/** Checks if the data state has been initialized. */ /** Checks if the data state has been initialized. */
private fun isInitialized(): Boolean { private fun isInitialized(): Boolean {
return prefs.getBoolean(KEY_INITIALIZED, false) return prefs.getBoolean(KEY_INITIALIZED, false)
@@ -70,11 +56,7 @@ class DataStateManager(context: Context) {
/** Marks the data state as initialized. */ /** Marks the data state as initialized. */
private fun markAsInitialized() { private fun markAsInitialized() {
prefs.edit().putBoolean(KEY_INITIALIZED, true).apply() prefs.edit { putBoolean(KEY_INITIALIZED, true) }
} }
/** Gets debug information about the current state. */
fun getDebugInfo(): String {
return "DataState(lastModified=${getLastModified()}, initialized=${isInitialized()})"
}
} }

View File

@@ -89,10 +89,6 @@ class SessionTrackingService : Service() {
return START_REDELIVER_INTENT return START_REDELIVER_INTENT
} }
override fun onTaskRemoved(rootIntent: Intent?) {
super.onTaskRemoved(rootIntent)
}
override fun onBind(intent: Intent?): IBinder? = null override fun onBind(intent: Intent?): IBinder? = null
private fun startSessionTracking(sessionId: String) { private fun startSessionTracking(sessionId: String) {
@@ -153,7 +149,7 @@ class SessionTrackingService : Service() {
return try { return try {
val activeNotifications = notificationManager.activeNotifications val activeNotifications = notificationManager.activeNotifications
activeNotifications.any { it.id == NOTIFICATION_ID } activeNotifications.any { it.id == NOTIFICATION_ID }
} catch (e: Exception) { } catch (_: Exception) {
false false
} }
} }

View File

@@ -4,6 +4,9 @@ import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.LazyRow import androidx.compose.foundation.lazy.LazyRow
import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.items
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.CheckCircle
import androidx.compose.material.icons.filled.Image
import androidx.compose.material3.* import androidx.compose.material3.*
import androidx.compose.runtime.* import androidx.compose.runtime.*
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
@@ -13,11 +16,11 @@ 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.data.model.Attempt
import com.atridad.openclimb.data.model.AttemptResult
import com.atridad.openclimb.data.model.ClimbType import com.atridad.openclimb.data.model.ClimbType
import com.atridad.openclimb.data.model.Gym 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.ImageDisplay
import com.atridad.openclimb.ui.components.SyncIndicator import com.atridad.openclimb.ui.components.SyncIndicator
import com.atridad.openclimb.ui.viewmodel.ClimbViewModel import com.atridad.openclimb.ui.viewmodel.ClimbViewModel
@@ -26,10 +29,8 @@ import com.atridad.openclimb.ui.viewmodel.ClimbViewModel
fun ProblemsScreen(viewModel: ClimbViewModel, onNavigateToProblemDetail: (String) -> Unit) { fun ProblemsScreen(viewModel: ClimbViewModel, onNavigateToProblemDetail: (String) -> Unit) {
val problems by viewModel.problems.collectAsState() val problems by viewModel.problems.collectAsState()
val gyms by viewModel.gyms.collectAsState() val gyms by viewModel.gyms.collectAsState()
val attempts by viewModel.attempts.collectAsState()
val context = LocalContext.current val context = LocalContext.current
var showImageViewer by remember { mutableStateOf(false) }
var selectedImagePaths by remember { mutableStateOf<List<String>>(emptyList()) }
var selectedImageIndex by remember { mutableIntStateOf(0) }
// Filter state // Filter state
var selectedClimbType by remember { mutableStateOf<ClimbType?>(null) } var selectedClimbType by remember { mutableStateOf<ClimbType?>(null) }
@@ -178,12 +179,8 @@ fun ProblemsScreen(viewModel: ClimbViewModel, onNavigateToProblemDetail: (String
ProblemCard( ProblemCard(
problem = problem, problem = problem,
gymName = gyms.find { it.id == problem.gymId }?.name ?: "Unknown Gym", gymName = gyms.find { it.id == problem.gymId }?.name ?: "Unknown Gym",
attempts = attempts,
onClick = { onNavigateToProblemDetail(problem.id) }, onClick = { onNavigateToProblemDetail(problem.id) },
onImageClick = { imagePaths, index ->
selectedImagePaths = imagePaths
selectedImageIndex = index
showImageViewer = true
},
onToggleActive = { onToggleActive = {
val updatedProblem = problem.copy(isActive = !problem.isActive) val updatedProblem = problem.copy(isActive = !problem.isActive)
viewModel.updateProblem(updatedProblem, context) viewModel.updateProblem(updatedProblem, context)
@@ -194,15 +191,6 @@ fun ProblemsScreen(viewModel: ClimbViewModel, onNavigateToProblemDetail: (String
} }
} }
} }
// Fullscreen Image Viewer
if (showImageViewer && selectedImagePaths.isNotEmpty()) {
FullscreenImageViewer(
imagePaths = selectedImagePaths,
initialIndex = selectedImageIndex,
onDismiss = { showImageViewer = false }
)
}
} }
@OptIn(ExperimentalMaterial3Api::class) @OptIn(ExperimentalMaterial3Api::class)
@@ -210,10 +198,17 @@ fun ProblemsScreen(viewModel: ClimbViewModel, onNavigateToProblemDetail: (String
fun ProblemCard( fun ProblemCard(
problem: Problem, problem: Problem,
gymName: String, gymName: String,
attempts: List<Attempt>,
onClick: () -> Unit, onClick: () -> Unit,
onImageClick: ((List<String>, Int) -> Unit)? = null,
onToggleActive: (() -> Unit)? = null onToggleActive: (() -> Unit)? = null
) { ) {
val isCompleted =
attempts.any { attempt ->
attempt.problemId == problem.id &&
(attempt.result == AttemptResult.SUCCESS ||
attempt.result == AttemptResult.FLASH)
}
Card(onClick = onClick, modifier = Modifier.fillMaxWidth()) { Card(onClick = onClick, modifier = Modifier.fillMaxWidth()) {
Column(modifier = Modifier.fillMaxWidth().padding(16.dp)) { Column(modifier = Modifier.fillMaxWidth().padding(16.dp)) {
Row( Row(
@@ -242,12 +237,35 @@ fun ProblemCard(
} }
Column(horizontalAlignment = Alignment.End) { Column(horizontalAlignment = Alignment.End) {
Text( Row(
text = problem.difficulty.grade, horizontalArrangement = Arrangement.spacedBy(8.dp),
style = MaterialTheme.typography.titleMedium, verticalAlignment = Alignment.CenterVertically
fontWeight = FontWeight.Bold, ) {
color = MaterialTheme.colorScheme.primary if (problem.imagePaths.isNotEmpty()) {
) Icon(
imageVector = Icons.Default.Image,
contentDescription = "Has images",
modifier = Modifier.size(16.dp),
tint = MaterialTheme.colorScheme.primary
)
}
if (isCompleted) {
Icon(
imageVector = Icons.Default.CheckCircle,
contentDescription = "Completed",
modifier = Modifier.size(16.dp),
tint = MaterialTheme.colorScheme.tertiary
)
}
Text(
text = problem.difficulty.grade,
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.Bold,
color = MaterialTheme.colorScheme.primary
)
}
Text( Text(
text = problem.climbType.getDisplayName(), text = problem.climbType.getDisplayName(),
@@ -279,16 +297,6 @@ fun ProblemCard(
} }
} }
// Display images if any
if (problem.imagePaths.isNotEmpty()) {
Spacer(modifier = Modifier.height(8.dp))
ImageDisplay(
imagePaths = problem.imagePaths.take(3), // Show max 3 images in list
imageSize = 60,
onImageClick = { index -> onImageClick?.invoke(problem.imagePaths, index) }
)
}
if (!problem.isActive) { if (!problem.isActive) {
Spacer(modifier = Modifier.height(8.dp)) Spacer(modifier = Modifier.height(8.dp))
Text( Text(

View File

@@ -44,9 +44,9 @@ fun SettingsScreen(viewModel: ClimbViewModel) {
var showResetDialog by remember { mutableStateOf(false) } var showResetDialog by remember { mutableStateOf(false) }
var showSyncConfigDialog by remember { mutableStateOf(false) } var showSyncConfigDialog by remember { mutableStateOf(false) }
var showDisconnectDialog by remember { mutableStateOf(false) } var showDisconnectDialog by remember { mutableStateOf(false) }
var showFixImagesDialog by remember { mutableStateOf(false) }
var showDeleteImagesDialog by remember { mutableStateOf(false) } var showDeleteImagesDialog by remember { mutableStateOf(false) }
var isFixingImages by remember { mutableStateOf(false) }
var isDeletingImages by remember { mutableStateOf(false) } var isDeletingImages by remember { mutableStateOf(false) }
// Sync configuration state // Sync configuration state
@@ -484,46 +484,6 @@ fun SettingsScreen(viewModel: ClimbViewModel) {
Spacer(modifier = Modifier.height(8.dp)) 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("Fix Image Names") },
supportingContent = {
Text(
"Rename all images to use consistent naming across devices"
)
},
leadingContent = {
Icon(Icons.Default.Build, contentDescription = null)
},
trailingContent = {
TextButton(
onClick = { showFixImagesDialog = true },
enabled = !isFixingImages && !uiState.isLoading
) {
if (isFixingImages) {
CircularProgressIndicator(
modifier = Modifier.size(16.dp),
strokeWidth = 2.dp
)
} else {
Text("Fix Names")
}
}
}
)
}
Spacer(modifier = Modifier.height(8.dp))
Card( Card(
shape = RoundedCornerShape(12.dp), shape = RoundedCornerShape(12.dp),
colors = colors =
@@ -1005,35 +965,6 @@ fun SettingsScreen(viewModel: ClimbViewModel) {
) )
} }
// Fix Image Names dialog
if (showFixImagesDialog) {
AlertDialog(
onDismissRequest = { showFixImagesDialog = false },
title = { Text("Fix Image Names") },
text = {
Text(
"This will rename all existing image files to use a consistent naming system across devices.\n\nThis improves sync reliability between iOS and Android. Your images will not be lost, only renamed.\n\nThis is safe to run multiple times."
)
},
confirmButton = {
TextButton(
onClick = {
isFixingImages = true
showFixImagesDialog = false
coroutineScope.launch {
viewModel.migrateImageNamesToDeterministic(context)
isFixingImages = false
viewModel.setMessage("Image names fixed successfully!")
}
}
) { Text("Fix Names") }
},
dismissButton = {
TextButton(onClick = { showFixImagesDialog = false }) { Text("Cancel") }
}
)
}
// Delete All Images dialog // Delete All Images dialog
if (showDeleteImagesDialog) { if (showDeleteImagesDialog) {
AlertDialog( AlertDialog(

View File

@@ -171,64 +171,6 @@ class ClimbViewModel(
val referencedImagePaths = allProblems.flatMap { it.imagePaths }.toSet() val referencedImagePaths = allProblems.flatMap { it.imagePaths }.toSet()
ImageUtils.cleanupOrphanedImages(context, referencedImagePaths) ImageUtils.cleanupOrphanedImages(context, referencedImagePaths)
} }
fun migrateImageNamesToDeterministic(context: Context) {
viewModelScope.launch {
val allProblems = repository.getAllProblems().first()
var migrationCount = 0
val updatedProblems = mutableListOf<Problem>()
for (problem in allProblems) {
if (problem.imagePaths.isEmpty()) continue
var newImagePaths = mutableListOf<String>()
var problemNeedsUpdate = false
for ((index, imagePath) in problem.imagePaths.withIndex()) {
val currentFilename = File(imagePath).name
if (ImageNamingUtils.isValidImageFilename(currentFilename)) {
newImagePaths.add(imagePath)
continue
}
val deterministicName =
ImageNamingUtils.generateImageFilename(problem.id, index)
val imagesDir = ImageUtils.getImagesDirectory(context)
val oldFile = File(imagesDir, currentFilename)
val newFile = File(imagesDir, deterministicName)
if (oldFile.exists()) {
if (oldFile.renameTo(newFile)) {
newImagePaths.add(deterministicName)
problemNeedsUpdate = true
migrationCount++
println("Migrated: $currentFilename$deterministicName")
} else {
println("Failed to migrate $currentFilename")
newImagePaths.add(imagePath)
}
} else {
println("Warning: Image file not found: $currentFilename")
newImagePaths.add(imagePath)
}
}
if (problemNeedsUpdate) {
val updatedProblem = problem.copy(imagePaths = newImagePaths)
updatedProblems.add(updatedProblem)
}
}
for (updatedProblem in updatedProblems) {
repository.insertProblemWithoutSync(updatedProblem)
}
println(
"Migration completed: $migrationCount images renamed, ${updatedProblems.size} problems updated"
)
}
}
fun deleteAllImages(context: Context) { fun deleteAllImages(context: Context) {
viewModelScope.launch { viewModelScope.launch {

View File

@@ -465,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 = 24; CURRENT_PROJECT_VERSION = 25;
DEVELOPMENT_TEAM = 4BC9Y2LL4B; DEVELOPMENT_TEAM = 4BC9Y2LL4B;
DRIVERKIT_DEPLOYMENT_TARGET = 24.6; DRIVERKIT_DEPLOYMENT_TARGET = 24.6;
ENABLE_PREVIEWS = YES; ENABLE_PREVIEWS = YES;
@@ -513,7 +513,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 = 24; CURRENT_PROJECT_VERSION = 25;
DEVELOPMENT_TEAM = 4BC9Y2LL4B; DEVELOPMENT_TEAM = 4BC9Y2LL4B;
DRIVERKIT_DEPLOYMENT_TARGET = 24.6; DRIVERKIT_DEPLOYMENT_TARGET = 24.6;
ENABLE_PREVIEWS = YES; ENABLE_PREVIEWS = YES;
@@ -602,7 +602,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 = 24; CURRENT_PROJECT_VERSION = 25;
DEVELOPMENT_TEAM = 4BC9Y2LL4B; DEVELOPMENT_TEAM = 4BC9Y2LL4B;
GENERATE_INFOPLIST_FILE = YES; GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_FILE = SessionStatusLive/Info.plist; INFOPLIST_FILE = SessionStatusLive/Info.plist;
@@ -632,7 +632,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 = 24; CURRENT_PROJECT_VERSION = 25;
DEVELOPMENT_TEAM = 4BC9Y2LL4B; DEVELOPMENT_TEAM = 4BC9Y2LL4B;
GENERATE_INFOPLIST_FILE = YES; GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_FILE = SessionStatusLive/Info.plist; INFOPLIST_FILE = SessionStatusLive/Info.plist;

View File

@@ -0,0 +1,39 @@
import SwiftUI
struct AsyncImageView: View {
let imagePath: String
let targetSize: CGSize
@State private var image: UIImage?
var body: some View {
ZStack {
Rectangle()
.fill(Color(.systemGray6))
if let image = image {
Image(uiImage: image)
.resizable()
.scaledToFill()
.transition(.opacity.animation(.easeInOut(duration: 0.3)))
} else {
Image(systemName: "photo")
.font(.system(size: 24))
.foregroundColor(Color(.systemGray3))
}
}
.frame(width: targetSize.width, height: targetSize.height)
.clipped()
.cornerRadius(8)
.task(id: imagePath) {
if self.image != nil {
self.image = nil
}
self.image = await ImageManager.shared.loadThumbnail(
fromPath: imagePath,
targetSize: targetSize
)
}
}
}

View File

@@ -9,6 +9,7 @@ class SyncService: ObservableObject {
@Published var syncError: String? @Published var syncError: String?
@Published var isConnected = false @Published var isConnected = false
@Published var isTesting = false @Published var isTesting = false
@Published var isOfflineMode = false
private let userDefaults = UserDefaults.standard private let userDefaults = UserDefaults.standard
private var syncTask: Task<Void, Never>? private var syncTask: Task<Void, Never>?
@@ -19,8 +20,9 @@ class SyncService: ObservableObject {
static let serverURL = "sync_server_url" static let serverURL = "sync_server_url"
static let authToken = "sync_auth_token" static let authToken = "sync_auth_token"
static let lastSyncTime = "last_sync_time" static let lastSyncTime = "last_sync_time"
static let isConnected = "sync_is_connected" static let isConnected = "is_connected"
static let autoSyncEnabled = "auto_sync_enabled" static let autoSyncEnabled = "auto_sync_enabled"
static let offlineMode = "offline_mode"
} }
var serverURL: String { var serverURL: String {
@@ -46,12 +48,9 @@ class SyncService: ObservableObject {
if let lastSync = userDefaults.object(forKey: Keys.lastSyncTime) as? Date { if let lastSync = userDefaults.object(forKey: Keys.lastSyncTime) as? Date {
self.lastSyncTime = lastSync self.lastSyncTime = lastSync
} }
self.isConnected = userDefaults.bool(forKey: Keys.isConnected) isConnected = userDefaults.bool(forKey: Keys.isConnected)
isAutoSyncEnabled = userDefaults.object(forKey: Keys.autoSyncEnabled) as? Bool ?? true
// Perform image naming migration on initialization isOfflineMode = userDefaults.bool(forKey: Keys.offlineMode)
Task {
await performImageNamingMigration()
}
} }
func downloadData() async throws -> ClimbDataBackup { func downloadData() async throws -> ClimbDataBackup {
@@ -211,6 +210,11 @@ class SyncService: ObservableObject {
} }
func syncWithServer(dataManager: ClimbingDataManager) async throws { func syncWithServer(dataManager: ClimbingDataManager) async throws {
if isOfflineMode {
print("Sync skipped: Offline mode is enabled.")
return
}
guard isConfigured else { guard isConfigured else {
throw SyncError.notConfigured throw SyncError.notConfigured
} }
@@ -1025,105 +1029,7 @@ class SyncService: ObservableObject {
syncTask?.cancel() syncTask?.cancel()
} }
// MARK: - Image Naming Migration // MARK: - Merging
private func performImageNamingMigration() async {
let migrationKey = "image_naming_migration_completed_v2"
guard !userDefaults.bool(forKey: migrationKey) else {
print("Image naming migration already completed")
return
}
print("Starting image naming migration...")
var updateCount = 0
let imageManager = ImageManager.shared
// Get all problems from UserDefaults
if let problemsData = userDefaults.data(forKey: "problems"),
var problems = try? JSONDecoder().decode([Problem].self, from: problemsData)
{
for problemIndex in 0..<problems.count {
let problem = problems[problemIndex]
guard !problem.imagePaths.isEmpty else { continue }
var updatedImagePaths: [String] = []
var hasChanges = false
for (imageIndex, imagePath) in problem.imagePaths.enumerated() {
let currentFilename = URL(fileURLWithPath: imagePath).lastPathComponent
let consistentFilename = ImageNamingUtils.generateImageFilename(
problemId: problem.id.uuidString, imageIndex: imageIndex)
if currentFilename != consistentFilename {
let oldPath = imageManager.imagesDirectory.appendingPathComponent(
currentFilename
).path
let newPath = imageManager.imagesDirectory.appendingPathComponent(
consistentFilename
).path
if FileManager.default.fileExists(atPath: oldPath) {
do {
try FileManager.default.moveItem(atPath: oldPath, toPath: newPath)
updatedImagePaths.append(consistentFilename)
hasChanges = true
updateCount += 1
print("Migrated image: \(currentFilename) -> \(consistentFilename)")
} catch {
print("Failed to migrate image \(currentFilename): \(error)")
updatedImagePaths.append(imagePath)
}
} else {
updatedImagePaths.append(imagePath)
}
} else {
updatedImagePaths.append(imagePath)
}
}
if hasChanges {
// Decode problem to dictionary, update imagePaths, re-encode
if let problemData = try? JSONEncoder().encode(problem),
var problemDict = try? JSONSerialization.jsonObject(with: problemData)
as? [String: Any]
{
problemDict["imagePaths"] = updatedImagePaths
problemDict["updatedAt"] = ISO8601DateFormatter().string(from: Date())
if let updatedData = try? JSONSerialization.data(
withJSONObject: problemDict),
let updatedProblem = try? JSONDecoder().decode(
Problem.self, from: updatedData)
{
problems[problemIndex] = updatedProblem
}
}
}
}
if updateCount > 0 {
if let updatedData = try? JSONEncoder().encode(problems) {
userDefaults.set(updatedData, forKey: "problems")
print("Updated \(updateCount) image paths in UserDefaults")
}
}
}
userDefaults.set(true, forKey: migrationKey)
print("Image naming migration completed, updated \(updateCount) images")
// Notify ClimbingDataManager to reload data if images were updated
if updateCount > 0 {
DispatchQueue.main.async {
NotificationCenter.default.post(
name: NSNotification.Name("ImageMigrationCompleted"),
object: nil,
userInfo: ["updateCount": updateCount]
)
}
}
}
// MARK: - Safe Merge Functions // MARK: - Safe Merge Functions
private func mergeGyms(local: [Gym], server: [BackupGym], deletedItems: [DeletedItem]) -> [Gym] private func mergeGyms(local: [Gym], server: [BackupGym], deletedItems: [DeletedItem]) -> [Gym]

View File

@@ -1,10 +1,12 @@
import Foundation import Foundation
import ImageIO
import SwiftUI import SwiftUI
import UIKit import UIKit
class ImageManager { class ImageManager {
static let shared = ImageManager() static let shared = ImageManager()
private let thumbnailCache = NSCache<NSString, UIImage>()
private let fileManager = FileManager.default private let fileManager = FileManager.default
private let appSupportDirectoryName = "OpenClimb" private let appSupportDirectoryName = "OpenClimb"
private let imagesDirectoryName = "Images" private let imagesDirectoryName = "Images"
@@ -479,6 +481,51 @@ class ImageManager {
return nil return nil
} }
func loadThumbnail(fromPath path: String, targetSize: CGSize) async -> UIImage? {
let cacheKey = "\(path)-\(targetSize.width)x\(targetSize.height)" as NSString
if let cachedImage = thumbnailCache.object(forKey: cacheKey) {
return cachedImage
}
guard let imageData = loadImageData(fromPath: path) else {
return nil
}
let options: [CFString: Any] = [
kCGImageSourceCreateThumbnailFromImageIfAbsent: true,
kCGImageSourceCreateThumbnailWithTransform: true,
kCGImageSourceShouldCacheImmediately: true,
kCGImageSourceThumbnailMaxPixelSize: max(targetSize.width, targetSize.height)
* UIScreen.main.scale,
]
guard let imageSource = CGImageSourceCreateWithData(imageData as CFData, nil) else {
return UIImage(data: imageData)
}
let properties = CGImageSourceCopyPropertiesAtIndex(imageSource, 0, nil) as? [CFString: Any]
let orientation = properties?[kCGImagePropertyOrientation] as? UInt32 ?? 1
if let cgImage = CGImageSourceCreateThumbnailAtIndex(
imageSource, 0, options as CFDictionary)
{
let imageOrientation = UIImage.Orientation(rawValue: Int(orientation - 1)) ?? .up
let thumbnail = UIImage(
cgImage: cgImage, scale: UIScreen.main.scale, orientation: imageOrientation)
thumbnailCache.setObject(thumbnail, forKey: cacheKey)
return thumbnail
} else {
if let fallbackImage = UIImage(data: imageData) {
thumbnailCache.setObject(fallbackImage, forKey: cacheKey)
return fallbackImage
}
}
return nil
}
func imageExists(atPath path: String) -> Bool { func imageExists(atPath path: String) -> Bool {
let primaryPath = getFullPath(from: path) let primaryPath = getFullPath(from: path)
let backupPath = backupDirectory.appendingPathComponent(getRelativePath(from: path)) let backupPath = backupDirectory.appendingPathComponent(getRelativePath(from: path))
@@ -854,72 +901,4 @@ class ImageManager {
} }
} }
func migrateImageNamesToDeterministic(dataManager: ClimbingDataManager) {
print("Starting migration of image names to deterministic format...")
var migrationCount = 0
var updatedProblems: [Problem] = []
for problem in dataManager.problems {
guard !problem.imagePaths.isEmpty else { continue }
var newImagePaths: [String] = []
var problemNeedsUpdate = false
for (index, imagePath) in problem.imagePaths.enumerated() {
let currentFilename = URL(fileURLWithPath: imagePath).lastPathComponent
if ImageNamingUtils.isValidImageFilename(currentFilename) {
newImagePaths.append(imagePath)
continue
}
let deterministicName = ImageNamingUtils.generateImageFilename(
problemId: problem.id.uuidString, imageIndex: index)
let oldPath = imagesDirectory.appendingPathComponent(currentFilename)
let newPath = imagesDirectory.appendingPathComponent(deterministicName)
if fileManager.fileExists(atPath: oldPath.path) {
do {
try fileManager.moveItem(at: oldPath, to: newPath)
let oldBackupPath = backupDirectory.appendingPathComponent(currentFilename)
let newBackupPath = backupDirectory.appendingPathComponent(
deterministicName)
if fileManager.fileExists(atPath: oldBackupPath.path) {
try? fileManager.moveItem(at: oldBackupPath, to: newBackupPath)
}
newImagePaths.append(deterministicName)
problemNeedsUpdate = true
migrationCount += 1
print("Migrated: \(currentFilename)\(deterministicName)")
} catch {
print("Failed to migrate \(currentFilename): \(error)")
newImagePaths.append(imagePath)
}
} else {
print("Warning: Image file not found: \(currentFilename)")
newImagePaths.append(imagePath)
}
}
if problemNeedsUpdate {
let updatedProblem = problem.updated(imagePaths: newImagePaths)
updatedProblems.append(updatedProblem)
}
}
for updatedProblem in updatedProblems {
dataManager.updateProblem(updatedProblem)
}
print(
"Migration completed: \(migrationCount) images renamed, \(updatedProblems.count) problems updated"
)
}
} }

View File

@@ -37,7 +37,7 @@ struct OrientationAwareImage: View {
} }
private func loadImageWithCorrectOrientation() { private func loadImageWithCorrectOrientation() {
Task { Task.detached(priority: .userInitiated) {
let correctedImage = await loadAndCorrectImage() let correctedImage = await loadAndCorrectImage()
await MainActor.run { await MainActor.run {
self.uiImage = correctedImage self.uiImage = correctedImage
@@ -48,17 +48,10 @@ struct OrientationAwareImage: View {
} }
private func loadAndCorrectImage() async -> UIImage? { private func loadAndCorrectImage() async -> UIImage? {
// Load image data from ImageManager guard let data = ImageManager.shared.loadImageData(fromPath: imagePath) else { return nil }
guard
let data = await MainActor.run(body: {
ImageManager.shared.loadImageData(fromPath: imagePath)
})
else { return nil }
// Create UIImage from data
guard let originalImage = UIImage(data: data) else { return nil } guard let originalImage = UIImage(data: data) else { return nil }
// Apply orientation correction
return correctImageOrientation(originalImage) return correctImageOrientation(originalImage)
} }

View File

@@ -9,8 +9,26 @@ struct ProblemsView: View {
@State private var showingSearch = false @State private var showingSearch = false
@FocusState private var isSearchFocused: Bool @FocusState private var isSearchFocused: Bool
private var filteredProblems: [Problem] { @State private var cachedFilteredProblems: [Problem] = []
var filtered = dataManager.problems
private func updateFilteredProblems() {
Task(priority: .userInitiated) {
let result = await computeFilteredProblems()
// Switch back to the main thread to update the UI
await MainActor.run {
cachedFilteredProblems = result
}
}
}
private func computeFilteredProblems() async -> [Problem] {
// Capture dependencies for safe background processing
let problems = dataManager.problems
let searchText = self.searchText
let selectedClimbType = self.selectedClimbType
let selectedGym = self.selectedGym
var filtered = problems
// Apply search filter // Apply search filter
if !searchText.isEmpty { if !searchText.isEmpty {
@@ -93,19 +111,19 @@ struct ProblemsView: View {
FilterSection( FilterSection(
selectedClimbType: $selectedClimbType, selectedClimbType: $selectedClimbType,
selectedGym: $selectedGym, selectedGym: $selectedGym,
filteredProblems: filteredProblems filteredProblems: cachedFilteredProblems
) )
.padding() .padding()
.background(.regularMaterial) .background(.regularMaterial)
} }
if filteredProblems.isEmpty { if cachedFilteredProblems.isEmpty {
EmptyProblemsView( EmptyProblemsView(
isEmpty: dataManager.problems.isEmpty, isEmpty: dataManager.problems.isEmpty,
isFiltered: !dataManager.problems.isEmpty isFiltered: !dataManager.problems.isEmpty
) )
} else { } else {
ProblemsList(problems: filteredProblems) ProblemsList(problems: cachedFilteredProblems)
} }
} }
} }
@@ -158,6 +176,21 @@ struct ProblemsView: View {
AddEditProblemView() AddEditProblemView()
} }
} }
.onAppear {
updateFilteredProblems()
}
.onChange(of: dataManager.problems) {
updateFilteredProblems()
}
.onChange(of: searchText) {
updateFilteredProblems()
}
.onChange(of: selectedClimbType) {
updateFilteredProblems()
}
.onChange(of: selectedGym) {
updateFilteredProblems()
}
} }
} }
@@ -269,6 +302,7 @@ struct ProblemsList: View {
@EnvironmentObject var dataManager: ClimbingDataManager @EnvironmentObject var dataManager: ClimbingDataManager
@State private var problemToDelete: Problem? @State private var problemToDelete: Problem?
@State private var problemToEdit: Problem? @State private var problemToEdit: Problem?
@State private var animationKey = 0
var body: some View { var body: some View {
List(problems, id: \.id) { problem in List(problems, id: \.id) { problem in
@@ -309,8 +343,11 @@ struct ProblemsList: View {
} }
.animation( .animation(
.spring(response: 0.5, dampingFraction: 0.8, blendDuration: 0.1), .spring(response: 0.5, dampingFraction: 0.8, blendDuration: 0.1),
value: problems.map { "\($0.id):\($0.isActive)" }.joined() value: animationKey
) )
.onChange(of: problems) {
animationKey += 1
}
.listStyle(.plain) .listStyle(.plain)
.scrollContentBackground(.hidden) .scrollContentBackground(.hidden)
.scrollIndicators(.hidden) .scrollIndicators(.hidden)
@@ -344,6 +381,12 @@ struct ProblemRow: View {
dataManager.gym(withId: problem.gymId) dataManager.gym(withId: problem.gymId)
} }
private var isCompleted: Bool {
dataManager.attempts.contains { attempt in
attempt.problemId == problem.id && attempt.result.isSuccessful
}
}
var body: some View { var body: some View {
VStack(alignment: .leading, spacing: 8) { VStack(alignment: .leading, spacing: 8) {
HStack { HStack {
@@ -361,10 +404,24 @@ struct ProblemRow: View {
Spacer() Spacer()
VStack(alignment: .trailing, spacing: 4) { VStack(alignment: .trailing, spacing: 4) {
Text(problem.difficulty.grade) HStack(spacing: 8) {
.font(.title2) if !problem.imagePaths.isEmpty {
.fontWeight(.bold) Image(systemName: "photo")
.foregroundColor(.blue) .font(.system(size: 14, weight: .medium))
.foregroundColor(.blue)
}
if isCompleted {
Image(systemName: "checkmark.circle.fill")
.font(.system(size: 14, weight: .medium))
.foregroundColor(.green)
}
Text(problem.difficulty.grade)
.font(.title2)
.fontWeight(.bold)
.foregroundColor(.blue)
}
Text(problem.climbType.displayName) Text(problem.climbType.displayName)
.font(.caption) .font(.caption)
@@ -396,17 +453,6 @@ struct ProblemRow: View {
} }
} }
if !problem.imagePaths.isEmpty {
ScrollView(.horizontal, showsIndicators: false) {
LazyHStack(spacing: 8) {
ForEach(problem.imagePaths.prefix(3), id: \.self) { imagePath in
ProblemImageView(imagePath: imagePath)
}
}
.padding(.horizontal, 4)
}
}
if !problem.isActive { if !problem.isActive {
Text("Reset / No Longer Set") Text("Reset / No Longer Set")
.font(.caption) .font(.caption)
@@ -478,17 +524,6 @@ struct EmptyProblemsView: View {
} }
} }
struct ProblemImageView: View {
let imagePath: String
var body: some View {
OrientationAwareImage.fill(imagePath: imagePath)
.frame(width: 60, height: 60)
.clipped()
.cornerRadius(8)
}
}
#Preview { #Preview {
ProblemsView() ProblemsView()
.environmentObject(ClimbingDataManager.preview) .environmentObject(ClimbingDataManager.preview)

View File

@@ -80,8 +80,7 @@ struct DataManagementSection: View {
@Binding var activeSheet: SheetType? @Binding var activeSheet: SheetType?
@State private var showingResetAlert = false @State private var showingResetAlert = false
@State private var isExporting = false @State private var isExporting = false
@State private var isMigrating = false
@State private var showingMigrationAlert = false
@State private var isDeletingImages = false @State private var isDeletingImages = false
@State private var showingDeleteImagesAlert = false @State private var showingDeleteImagesAlert = false
@@ -121,27 +120,6 @@ struct DataManagementSection: View {
} }
.foregroundColor(.primary) .foregroundColor(.primary)
// Migrate Image Names
Button(action: {
showingMigrationAlert = true
}) {
HStack {
if isMigrating {
ProgressView()
.scaleEffect(0.8)
Text("Migrating Images...")
.foregroundColor(.secondary)
} else {
Image(systemName: "photo.badge.arrow.down")
.foregroundColor(.orange)
Text("Fix Image Names")
}
Spacer()
}
}
.disabled(isMigrating)
.foregroundColor(.primary)
// Delete All Images // Delete All Images
Button(action: { Button(action: {
showingDeleteImagesAlert = true showingDeleteImagesAlert = true
@@ -186,16 +164,7 @@ struct DataManagementSection: View {
"Are you sure you want to reset all data? This will permanently delete:\n\n• All gyms and their information\n• All problems and their images\n• All climbing sessions\n• All attempts and progress data\n\nThis action cannot be undone. Consider exporting your data first." "Are you sure you want to reset all data? This will permanently delete:\n\n• All gyms and their information\n• All problems and their images\n• All climbing sessions\n• All attempts and progress data\n\nThis action cannot be undone. Consider exporting your data first."
) )
} }
.alert("Fix Image Names", isPresented: $showingMigrationAlert) {
Button("Cancel", role: .cancel) {}
Button("Fix Names") {
migrateImageNames()
}
} message: {
Text(
"This will rename all existing image files to use a consistent naming system across devices.\n\nThis improves sync reliability between iOS and Android. Your images will not be lost, only renamed.\n\nThis is safe to run multiple times."
)
}
.alert("Delete All Images", isPresented: $showingDeleteImagesAlert) { .alert("Delete All Images", isPresented: $showingDeleteImagesAlert) {
Button("Cancel", role: .cancel) {} Button("Cancel", role: .cancel) {}
Button("Delete", role: .destructive) { Button("Delete", role: .destructive) {
@@ -219,17 +188,6 @@ struct DataManagementSection: View {
} }
} }
private func migrateImageNames() {
isMigrating = true
Task {
await MainActor.run {
ImageManager.shared.migrateImageNamesToDeterministic(dataManager: dataManager)
isMigrating = false
dataManager.successMessage = "Image names fixed successfully!"
}
}
}
private func deleteAllImages() { private func deleteAllImages() {
isDeletingImages = true isDeletingImages = true
Task { Task {

23
ios/README.md Normal file
View File

@@ -0,0 +1,23 @@
# OpenClimb for iOS
The native iOS, watchOS, and widget client for OpenClimb, built with Swift and SwiftUI.
## Project Structure
This is a standard Xcode project. The main app code is in the `OpenClimb/` directory.
- `Models/`: Swift `Codable` models (`Problem`, `Gym`, `ClimbSession`) that match the Android app.
- `ViewModels/`: App state and logic. `ClimbingDataManager` is the core here, handling data with SwiftData.
- `Views/`: All the SwiftUI views.
- `AddEdit/`: Views for adding/editing gyms, problems, etc.
- `Detail/`: Detail views for items.
- `Services/`: Handles HealthKit and sync server communication.
- `Utils/`: Helper functions and utilities.
## Other Targets
- `OpenClimbWatch/`: The watchOS app for tracking sessions.
- `ClimbingActivityWidget/`: A home screen widget.
- `SessionStatusLive/`: A Live Activity for the lock screen.
The app is built to be offline-first. All data is stored locally on your device and works without an internet connection.

37
sync/README.md Normal file
View File

@@ -0,0 +1,37 @@
# Sync Server
A simple Go server for self-hosting your OpenClimb sync data.
## How It Works
This server is dead simple. It uses a single `openclimb.json` file for your data and a directory for images. The last client to upload wins, overwriting the old data. Authentication is just a static bearer token.
## Getting Started
1. Create a `.env` file in this directory:
```
IMAGE=git.atri.dad/atridad/openclimb-sync:latest
APP_PORT=8080
AUTH_TOKEN=your-super-secret-token
DATA_FILE=/data/openclimb.json
IMAGES_DIR=/data/images
ROOT_DIR=./openclimb-data
```
Set `AUTH_TOKEN` to a long, random string. `ROOT_DIR` is where the server will store its data on your machine.
2. Run with Docker:
```bash
docker-compose up -d
```
The server will be running on `http://localhost:8080`.
## API
The API is minimal, just enough for the app to work. All endpoints require an `Authorization: Bearer <your-auth-token>` header.
- `GET /sync`: Download `openclimb.json`.
- `POST /sync`: Upload `openclimb.json`.
- `GET /images/{imageName}`: Download an image.
- `POST /images/{imageName}`: Upload an image.
Check out `main.go` for the full details.