iOS Build 23

This commit is contained in:
2025-10-11 18:54:24 -06:00
parent e7c46634da
commit 53fa74cc83
23 changed files with 1351 additions and 285 deletions

View File

@@ -16,7 +16,7 @@ android {
applicationId = "com.atridad.openclimb"
minSdk = 31
targetSdk = 36
versionCode = 35
versionCode = 36
versionName = "1.8.0"
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
@@ -60,6 +60,7 @@ dependencies {
// Room Database
implementation(libs.androidx.room.runtime)
implementation(libs.androidx.room.ktx)
implementation(libs.androidx.exifinterface)
ksp(libs.androidx.room.compiler)

View File

@@ -25,6 +25,8 @@ import java.io.IOException
import java.time.Instant
import java.util.concurrent.TimeUnit
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
@@ -54,9 +56,9 @@ class SyncService(private val context: Context, private val repository: ClimbRep
private val httpClient =
OkHttpClient.Builder()
.connectTimeout(30, TimeUnit.SECONDS)
.readTimeout(60, TimeUnit.SECONDS)
.writeTimeout(60, TimeUnit.SECONDS)
.connectTimeout(45, TimeUnit.SECONDS)
.readTimeout(90, TimeUnit.SECONDS)
.writeTimeout(90, TimeUnit.SECONDS)
.build()
private val json = Json {
@@ -86,6 +88,11 @@ class SyncService(private val context: Context, private val repository: ClimbRep
private val _isTesting = MutableStateFlow(false)
val isTesting: StateFlow<Boolean> = _isTesting.asStateFlow()
// Debounced sync properties
private var syncJob: Job? = null
private var pendingChanges = false
private val syncDebounceDelay = 2000L // 2 seconds
// Configuration keys
private object Keys {
const val SERVER_URL = "sync_server_url"
@@ -137,6 +144,9 @@ class SyncService(private val context: Context, private val repository: ClimbRep
repository.setAutoSyncCallback {
kotlinx.coroutines.CoroutineScope(Dispatchers.IO).launch { triggerAutoSync() }
}
// Perform image naming migration on initialization
kotlinx.coroutines.CoroutineScope(Dispatchers.IO).launch { performImageNamingMigration() }
}
suspend fun downloadData(): ClimbDataBackup =
@@ -297,6 +307,9 @@ class SyncService(private val context: Context, private val repository: ClimbRep
try {
val response = httpClient.newCall(request).execute()
Log.d(TAG, "Image download response for $filename: ${response.code}")
if (response.code != 200) {
Log.w(TAG, "Failed request URL: ${request.url}")
}
when (response.code) {
200 -> {
@@ -426,28 +439,23 @@ class SyncService(private val context: Context, private val repository: ClimbRep
totalImages += imageCount
}
problem.imagePaths?.forEachIndexed { index, imagePath ->
problem.imagePaths?.forEach { imagePath ->
try {
Log.d(TAG, "Attempting to download image: $imagePath")
val imageData = downloadImage(imagePath)
// Use the server's actual filename, not regenerated
val serverFilename = imagePath.substringAfterLast('/')
val consistentFilename =
if (ImageNamingUtils.isValidImageFilename(serverFilename)) {
serverFilename
} else {
ImageNamingUtils.generateImageFilename(problem.id, index)
}
Log.d(TAG, "Attempting to download image: $serverFilename")
val imageData = downloadImage(serverFilename)
val localImagePath =
ImageUtils.saveImageFromBytesWithFilename(
context,
imageData,
consistentFilename
serverFilename
)
if (localImagePath != null) {
imagePathMapping[serverFilename] = localImagePath
imagePathMapping[imagePath] = localImagePath
downloadedImages++
Log.d(
TAG,
@@ -457,6 +465,12 @@ class SyncService(private val context: Context, private val repository: ClimbRep
Log.w(TAG, "Failed to save downloaded image locally: $imagePath")
failedImages++
}
} catch (e: SyncException.ImageNotFound) {
Log.w(
TAG,
"Image not found on server: $imagePath - might be missing or use different naming"
)
failedImages++
} catch (e: Exception) {
Log.w(TAG, "Failed to download image $imagePath: ${e.message}")
failedImages++
@@ -495,32 +509,24 @@ class SyncService(private val context: Context, private val repository: ClimbRep
if (imageFile.exists() && imageFile.length() > 0) {
val imageData = imageFile.readBytes()
// Always use consistent problem-based naming for uploads
val consistentFilename =
ImageNamingUtils.generateImageFilename(problem.id, index)
val filename = imagePath.substringAfterLast('/')
val consistentFilename =
if (ImageNamingUtils.isValidImageFilename(filename)) {
filename
} else {
val newFilename =
ImageNamingUtils.generateImageFilename(
problem.id,
index
)
val newFile = java.io.File(imageFile.parent, newFilename)
if (imageFile.renameTo(newFile)) {
Log.d(
TAG,
"Renamed local image file: $filename -> $newFilename"
)
newFilename
} else {
Log.w(
TAG,
"Failed to rename local image file, using original"
)
filename
}
}
// Rename local file if needed
if (filename != consistentFilename) {
val newFile = java.io.File(imageFile.parent, consistentFilename)
if (imageFile.renameTo(newFile)) {
Log.d(
TAG,
"Renamed local image file: $filename -> $consistentFilename"
)
} else {
Log.w(TAG, "Failed to rename local image file, using original")
}
}
Log.d(TAG, "Uploading image: $consistentFilename (${imageData.size} bytes)")
uploadImage(consistentFilename, imageData)
@@ -568,7 +574,21 @@ class SyncService(private val context: Context, private val repository: ClimbRep
version = "2.0",
formatVersion = "2.0",
gyms = allGyms.map { BackupGym.fromGym(it) },
problems = allProblems.map { BackupProblem.fromProblem(it) },
problems =
allProblems.map { problem ->
// Normalize image paths to consistent naming in backup
val normalizedImagePaths =
problem.imagePaths?.mapIndexed { index, _ ->
ImageNamingUtils.generateImageFilename(problem.id, index)
}
val backupProblem = BackupProblem.fromProblem(problem)
if (!normalizedImagePaths.isNullOrEmpty()) {
backupProblem.copy(imagePaths = normalizedImagePaths)
} else {
backupProblem
}
},
sessions = completedSessions.map { BackupClimbSession.fromClimbSession(it) },
attempts = completedAttempts.map { BackupAttempt.fromAttempt(it) },
deletedItems = repository.getDeletedItems()
@@ -851,10 +871,8 @@ class SyncService(private val context: Context, private val repository: ClimbRep
val localIds = local.map { it.id }.toSet()
val deletedGymIds = deletedItems.filter { it.type == "gym" }.map { it.id }.toSet()
// Remove items that were deleted on other devices
merged.removeAll { deletedGymIds.contains(it.id) }
// Add new items from server (excluding deleted ones)
server.forEach { serverGym ->
if (!localIds.contains(serverGym.id) && !deletedGymIds.contains(serverGym.id)) {
try {
@@ -878,24 +896,26 @@ class SyncService(private val context: Context, private val repository: ClimbRep
val localIds = local.map { it.id }.toSet()
val deletedProblemIds = deletedItems.filter { it.type == "problem" }.map { it.id }.toSet()
// Remove items that were deleted on other devices
merged.removeAll { deletedProblemIds.contains(it.id) }
// Add new items from server (excluding deleted ones)
server.forEach { serverProblem ->
if (!localIds.contains(serverProblem.id) &&
!deletedProblemIds.contains(serverProblem.id)
) {
try {
val problemToAdd =
if (imagePathMapping.isNotEmpty()) {
val newImagePaths =
serverProblem.imagePaths?.map { oldPath ->
val filename = oldPath.substringAfterLast('/')
imagePathMapping[filename] ?: oldPath
if (imagePathMapping.isNotEmpty() &&
!serverProblem.imagePaths.isNullOrEmpty()
) {
val updatedImagePaths =
serverProblem.imagePaths?.mapNotNull { oldPath ->
imagePathMapping[oldPath] ?: oldPath
}
?: emptyList()
serverProblem.withUpdatedImagePaths(newImagePaths)
if (updatedImagePaths != serverProblem.imagePaths) {
serverProblem.copy(imagePaths = updatedImagePaths)
} else {
serverProblem
}
} else {
serverProblem
}
@@ -918,10 +938,8 @@ class SyncService(private val context: Context, private val repository: ClimbRep
val localIds = local.map { it.id }.toSet()
val deletedSessionIds = deletedItems.filter { it.type == "session" }.map { it.id }.toSet()
// Remove items that were deleted on other devices (but never remove active sessions)
merged.removeAll { deletedSessionIds.contains(it.id) && it.status != SessionStatus.ACTIVE }
// Add new items from server (excluding deleted ones)
server.forEach { serverSession ->
if (!localIds.contains(serverSession.id) &&
!deletedSessionIds.contains(serverSession.id)
@@ -946,10 +964,8 @@ class SyncService(private val context: Context, private val repository: ClimbRep
val localIds = local.map { it.id }.toSet()
val deletedAttemptIds = deletedItems.filter { it.type == "attempt" }.map { it.id }.toSet()
// Remove items that were deleted on other devices (but be conservative with attempts)
merged.removeAll { deletedAttemptIds.contains(it.id) }
// Add new items from server (excluding deleted ones)
server.forEach { serverAttempt ->
if (!localIds.contains(serverAttempt.id) &&
!deletedAttemptIds.contains(serverAttempt.id)
@@ -1093,19 +1109,54 @@ class SyncService(private val context: Context, private val repository: ClimbRep
}
if (_isSyncing.value) {
Log.d(TAG, "Sync already in progress, skipping auto-sync")
pendingChanges = true
return
}
syncJob?.cancel()
syncJob =
kotlinx.coroutines.CoroutineScope(Dispatchers.IO).launch {
delay(syncDebounceDelay)
do {
pendingChanges = false
try {
syncWithServer()
} catch (e: Exception) {
Log.e(TAG, "Auto-sync failed: ${e.message}")
_syncError.value = e.message
return@launch
}
if (pendingChanges) {
delay(syncDebounceDelay)
}
} while (pendingChanges)
}
}
suspend fun forceSyncNow() {
if (!isConfigured || !_isConnected.value) return
syncJob?.cancel()
syncJob = null
pendingChanges = false
try {
syncWithServer()
} catch (e: Exception) {
Log.e(TAG, "Auto-sync failed: ${e.message}")
Log.e(TAG, "Force sync failed: ${e.message}")
_syncError.value = e.message
}
}
fun clearConfiguration() {
syncJob?.cancel()
syncJob = null
pendingChanges = false
serverURL = ""
authToken = ""
isAutoSyncEnabled = true
@@ -1116,6 +1167,113 @@ class SyncService(private val context: Context, private val repository: ClimbRep
sharedPreferences.edit().clear().apply()
updateConfiguredState()
}
// MARK: - Image Naming Migration
private suspend fun performImageNamingMigration() =
withContext(Dispatchers.IO) {
val migrationKey = "image_naming_migration_completed"
if (sharedPreferences.getBoolean(migrationKey, false)) {
Log.d(TAG, "Image naming migration already completed")
return@withContext
}
Log.d(TAG, "Starting image naming migration...")
var updateCount = 0
try {
// Get all problems with images
val problems = repository.getAllProblems().first()
val updatedProblems = mutableListOf<Problem>()
for (problem in problems) {
if (problem.imagePaths.isNullOrEmpty()) {
continue
}
val updatedImagePaths = mutableListOf<String>()
var hasChanges = false
problem.imagePaths.forEachIndexed { index, imagePath ->
val currentFilename = imagePath.substringAfterLast('/')
val consistentFilename =
ImageNamingUtils.generateImageFilename(problem.id, index)
if (currentFilename != consistentFilename) {
// Get the image file
val oldFile = ImageUtils.getImageFile(context, imagePath)
if (oldFile.exists()) {
val newPath = "problem_images/$consistentFilename"
val newFile = ImageUtils.getImageFile(context, newPath)
try {
// Create parent directory if needed
newFile.parentFile?.mkdirs()
if (oldFile.renameTo(newFile)) {
updatedImagePaths.add(newPath)
hasChanges = true
updateCount++
Log.d(
TAG,
"Migrated image: $currentFilename -> $consistentFilename"
)
} else {
Log.w(TAG, "Failed to migrate image $currentFilename")
updatedImagePaths.add(
imagePath
) // Keep original on failure
}
} catch (e: Exception) {
Log.w(
TAG,
"Failed to migrate image $currentFilename: ${e.message}"
)
updatedImagePaths.add(imagePath) // Keep original on failure
}
} else {
updatedImagePaths.add(
imagePath
) // Keep original if file doesn't exist
}
} else {
updatedImagePaths.add(imagePath) // Already consistent
}
}
if (hasChanges) {
val updatedProblem =
problem.copy(
imagePaths = updatedImagePaths,
updatedAt = DateFormatUtils.formatISO8601(Instant.now())
)
updatedProblems.add(updatedProblem)
}
}
// Update problems in database
if (updatedProblems.isNotEmpty()) {
updatedProblems.forEach { problem -> repository.updateProblem(problem) }
Log.d(
TAG,
"Updated ${updatedProblems.size} problems with migrated image paths"
)
}
// Mark migration as completed
sharedPreferences.edit().putBoolean(migrationKey, true).apply()
Log.d(TAG, "Image naming migration completed, updated $updateCount images")
// Trigger sync after migration if images were updated
if (updateCount > 0) {
Log.d(TAG, "Triggering sync after image migration")
triggerAutoSync()
}
} catch (e: Exception) {
Log.e(TAG, "Image naming migration failed: ${e.message}", e)
}
}
}
sealed class SyncException(message: String) : Exception(message) {

View File

@@ -56,7 +56,7 @@ fun ImagePicker(
// Process images
val newImagePaths = mutableListOf<String>()
urisToProcess.forEach { uri ->
val imagePath = ImageUtils.saveImageFromUri(context, uri)
val imagePath = ImageUtils.saveTemporaryImageFromUri(context, uri)
if (imagePath != null) {
newImagePaths.add(imagePath)
}
@@ -76,7 +76,7 @@ fun ImagePicker(
success ->
if (success) {
cameraImageUri?.let { uri ->
val imagePath = ImageUtils.saveImageFromUri(context, uri)
val imagePath = ImageUtils.saveTemporaryImageFromUri(context, uri)
if (imagePath != null) {
val updatedUris = tempImageUris + imagePath
tempImageUris = updatedUris

View File

@@ -248,6 +248,7 @@ fun AddEditProblemScreen(
) {
val isEditing = problemId != null
val gyms by viewModel.gyms.collectAsState()
val context = LocalContext.current
// Problem form state
var selectedGym by remember {
@@ -387,10 +388,11 @@ fun AddEditProblemScreen(
if (isEditing) {
viewModel.updateProblem(
problem.copy(id = problemId!!)
problem.copy(id = problemId),
context
)
} else {
viewModel.addProblem(problem)
viewModel.addProblem(problem, context)
}
onNavigateBack()
}

View File

@@ -537,7 +537,7 @@ fun SessionDetailScreen(
viewModel.addAttempt(attempt)
showAddAttemptDialog = false
},
onProblemCreated = { problem -> viewModel.addProblem(problem) }
onProblemCreated = { problem -> viewModel.addProblem(problem, context) }
)
}

View File

@@ -8,6 +8,7 @@ import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
@@ -25,6 +26,7 @@ import com.atridad.openclimb.ui.viewmodel.ClimbViewModel
fun ProblemsScreen(viewModel: ClimbViewModel, onNavigateToProblemDetail: (String) -> Unit) {
val problems by viewModel.problems.collectAsState()
val gyms by viewModel.gyms.collectAsState()
val context = LocalContext.current
var showImageViewer by remember { mutableStateOf(false) }
var selectedImagePaths by remember { mutableStateOf<List<String>>(emptyList()) }
var selectedImageIndex by remember { mutableIntStateOf(0) }
@@ -184,7 +186,7 @@ fun ProblemsScreen(viewModel: ClimbViewModel, onNavigateToProblemDetail: (String
},
onToggleActive = {
val updatedProblem = problem.copy(isActive = !problem.isActive)
viewModel.updateProblem(updatedProblem)
viewModel.updateProblem(updatedProblem, context)
}
)
Spacer(modifier = Modifier.height(8.dp))

View File

@@ -42,6 +42,10 @@ fun SettingsScreen(viewModel: ClimbViewModel) {
var showResetDialog by remember { mutableStateOf(false) }
var showSyncConfigDialog by remember { mutableStateOf(false) }
var showDisconnectDialog by remember { mutableStateOf(false) }
var showFixImagesDialog by remember { mutableStateOf(false) }
var showDeleteImagesDialog by remember { mutableStateOf(false) }
var isFixingImages by remember { mutableStateOf(false) }
var isDeletingImages by remember { mutableStateOf(false) }
// Sync configuration state
var serverUrl by remember { mutableStateOf(syncService.serverURL) }
@@ -475,6 +479,88 @@ fun SettingsScreen(viewModel: ClimbViewModel) {
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(
shape = RoundedCornerShape(12.dp),
colors =
CardDefaults.cardColors(
containerColor =
MaterialTheme.colorScheme.errorContainer.copy(
alpha = 0.3f
)
)
) {
ListItem(
headlineContent = { Text("Delete All Images") },
supportingContent = {
Text("Permanently delete all image files from device")
},
leadingContent = {
Icon(
Icons.Default.Delete,
contentDescription = null,
tint = MaterialTheme.colorScheme.error
)
},
trailingContent = {
TextButton(
onClick = { showDeleteImagesDialog = true },
enabled = !isDeletingImages && !uiState.isLoading
) {
if (isDeletingImages) {
CircularProgressIndicator(
modifier = Modifier.size(16.dp),
strokeWidth = 2.dp
)
} else {
Text("Delete", color = MaterialTheme.colorScheme.error)
}
}
}
)
}
Spacer(modifier = Modifier.height(8.dp))
Card(
shape = RoundedCornerShape(12.dp),
colors =
@@ -903,16 +989,72 @@ fun SettingsScreen(viewModel: ClimbViewModel) {
confirmButton = {
TextButton(
onClick = {
syncService.clearConfiguration()
serverUrl = ""
authToken = ""
viewModel.syncService.clearConfiguration()
showDisconnectDialog = false
}
) { Text("Disconnect", color = MaterialTheme.colorScheme.error) }
) { Text("Disconnect") }
},
dismissButton = {
TextButton(onClick = { showDisconnectDialog = false }) { Text("Cancel") }
}
)
}
// 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
if (showDeleteImagesDialog) {
AlertDialog(
onDismissRequest = { showDeleteImagesDialog = false },
title = { Text("Delete All Images") },
text = {
Text(
"This will permanently delete ALL image files from your device.\n\nProblems will keep their references but the actual image files will be removed. This cannot be undone.\n\nConsider exporting your data first if you want to keep your images."
)
},
confirmButton = {
TextButton(
onClick = {
isDeletingImages = true
showDeleteImagesDialog = false
coroutineScope.launch {
viewModel.deleteAllImages(context)
isDeletingImages = false
viewModel.setMessage("All images deleted successfully!")
}
}
) { Text("Delete", color = MaterialTheme.colorScheme.error) }
},
dismissButton = {
TextButton(onClick = { showDeleteImagesDialog = false }) { Text("Cancel") }
}
)
}
}

View File

@@ -7,6 +7,7 @@ import com.atridad.openclimb.data.model.*
import com.atridad.openclimb.data.repository.ClimbRepository
import com.atridad.openclimb.data.sync.SyncService
import com.atridad.openclimb.service.SessionTrackingService
import com.atridad.openclimb.utils.ImageNamingUtils
import com.atridad.openclimb.utils.ImageUtils
import com.atridad.openclimb.utils.SessionShareUtils
import com.atridad.openclimb.widget.ClimbStatsWidgetProvider
@@ -106,25 +107,41 @@ class ClimbViewModel(private val repository: ClimbRepository, val syncService: S
fun getGymById(id: String): Flow<Gym?> = flow { emit(repository.getGymById(id)) }
// Problem operations
fun addProblem(problem: Problem) {
viewModelScope.launch { repository.insertProblem(problem) }
}
fun addProblem(problem: Problem, context: Context) {
viewModelScope.launch {
repository.insertProblem(problem)
val finalProblem = renameTemporaryImages(problem, context)
repository.insertProblem(finalProblem)
ClimbStatsWidgetProvider.updateAllWidgets(context)
// Auto-sync now happens automatically via repository callback
}
}
fun updateProblem(problem: Problem) {
viewModelScope.launch { repository.updateProblem(problem) }
private suspend fun renameTemporaryImages(problem: Problem, context: Context? = null): Problem {
if (problem.imagePaths.isEmpty()) {
return problem
}
val appContext = context ?: return problem
val finalImagePaths = mutableListOf<String>()
problem.imagePaths.forEachIndexed { index, tempPath ->
if (tempPath.startsWith("temp_")) {
val deterministicName = ImageNamingUtils.generateImageFilename(problem.id, index)
val finalPath =
ImageUtils.renameTemporaryImage(appContext, tempPath, problem.id, index)
finalImagePaths.add(finalPath ?: tempPath)
} else {
finalImagePaths.add(tempPath)
}
}
return problem.copy(imagePaths = finalImagePaths)
}
fun updateProblem(problem: Problem, context: Context) {
viewModelScope.launch {
repository.updateProblem(problem)
val finalProblem = renameTemporaryImages(problem, context)
repository.updateProblem(finalProblem)
ClimbStatsWidgetProvider.updateAllWidgets(context)
}
}
@@ -147,6 +164,99 @@ class ClimbViewModel(private val repository: ClimbRepository, val syncService: S
val referencedImagePaths = allProblems.flatMap { it.imagePaths }.toSet()
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) {
viewModelScope.launch {
val imagesDir = ImageUtils.getImagesDirectory(context)
var deletedCount = 0
imagesDir.listFiles()?.forEach { file ->
if (file.isFile && file.extension.lowercase() == "jpg") {
if (file.delete()) {
deletedCount++
}
}
}
val allProblems = repository.getAllProblems().first()
val updatedProblems =
allProblems.map { problem ->
if (problem.imagePaths.isNotEmpty()) {
problem.copy(imagePaths = emptyList())
} else {
problem
}
}
for (updatedProblem in updatedProblems) {
if (updatedProblem.imagePaths !=
allProblems.find { it.id == updatedProblem.id }?.imagePaths
) {
repository.insertProblemWithoutSync(updatedProblem)
}
}
println("Deleted $deletedCount image files and cleared image references")
}
}
fun getProblemById(id: String): Flow<Problem?> = flow { emit(repository.getProblemById(id)) }
@@ -240,7 +350,6 @@ class ClimbViewModel(private val repository: ClimbRepository, val syncService: S
ClimbStatsWidgetProvider.updateAllWidgets(context)
android.util.Log.d("ClimbViewModel", "Session started successfully")
_uiState.value = _uiState.value.copy(message = "Session started successfully!")
}
}
@@ -268,8 +377,6 @@ class ClimbViewModel(private val repository: ClimbRepository, val syncService: S
ClimbStatsWidgetProvider.updateAllWidgets(context)
// Auto-sync now happens automatically via repository callback
_uiState.value = _uiState.value.copy(message = "Session completed!")
}
}
@@ -295,7 +402,6 @@ class ClimbViewModel(private val repository: ClimbRepository, val syncService: S
viewModelScope.launch {
repository.insertAttempt(attempt)
ClimbStatsWidgetProvider.updateAllWidgets(context)
// Auto-sync now happens automatically via repository callback
}
}
@@ -410,6 +516,10 @@ class ClimbViewModel(private val repository: ClimbRepository, val syncService: S
_uiState.value = _uiState.value.copy(error = message)
}
fun setMessage(message: String) {
_uiState.value = _uiState.value.copy(message = message)
}
fun resetAllData() {
viewModelScope.launch {
try {

View File

@@ -13,18 +13,16 @@ object ImageNamingUtils {
private const val HASH_LENGTH = 12 // First 12 chars of SHA-256
/** Generates a deterministic filename for a problem image */
fun generateImageFilename(problemId: String, timestamp: String, imageIndex: Int): String {
// Create a deterministic hash from problemId + timestamp + index
val input = "${problemId}_${timestamp}_${imageIndex}"
fun generateImageFilename(problemId: String, imageIndex: Int): String {
val input = "${problemId}_${imageIndex}"
val hash = createHash(input)
return "problem_${hash}_${imageIndex}${IMAGE_EXTENSION}"
}
/** Generates a deterministic filename using current timestamp */
fun generateImageFilename(problemId: String, imageIndex: Int): String {
val timestamp = DateFormatUtils.nowISO8601()
return generateImageFilename(problemId, timestamp, imageIndex)
/** Legacy method for backward compatibility */
fun generateImageFilename(problemId: String, timestamp: String, imageIndex: Int): String {
return generateImageFilename(problemId, imageIndex)
}
/** Extracts problem ID from an image filename */
@@ -41,9 +39,7 @@ object ImageNamingUtils {
return null
}
// We can't extract the original problem ID from the hash,
// but we can validate the format
return parts[1] // Return the hash as identifier
return parts[1]
}
/** Validates if a filename follows our naming convention */
@@ -63,15 +59,11 @@ object ImageNamingUtils {
/** Migrates an existing filename to our naming convention */
fun migrateFilename(oldFilename: String, problemId: String, imageIndex: Int): String {
// If it's already using our convention, keep it
if (isValidImageFilename(oldFilename)) {
return oldFilename
}
// Generate new deterministic name
// Use a timestamp based on the old filename to maintain some consistency
val timestamp = DateFormatUtils.nowISO8601()
return generateImageFilename(problemId, timestamp, imageIndex)
return generateImageFilename(problemId, imageIndex)
}
/** Creates a deterministic hash from input string */
@@ -90,7 +82,7 @@ object ImageNamingUtils {
val renameMap = mutableMapOf<String, String>()
existingFilenames.forEachIndexed { index, oldFilename ->
val newFilename = migrateFilename(oldFilename, problemId, index)
val newFilename = generateImageFilename(problemId, index)
if (newFilename != oldFilename) {
renameMap[oldFilename] = newFilename
}
@@ -98,4 +90,37 @@ object ImageNamingUtils {
return renameMap
}
/** Generates the canonical filename for a problem image */
fun getCanonicalImageFilename(problemId: String, imageIndex: Int): String {
return generateImageFilename(problemId, imageIndex)
}
/** Creates a mapping of existing server filenames to canonical filenames */
fun createServerMigrationMap(
problemId: String,
serverImageFilenames: List<String>,
localImageCount: Int
): Map<String, String> {
val migrationMap = mutableMapOf<String, String>()
for (imageIndex in 0 until localImageCount) {
val canonicalName = getCanonicalImageFilename(problemId, imageIndex)
if (serverImageFilenames.contains(canonicalName)) {
continue
}
for (serverFilename in serverImageFilenames) {
if (isValidImageFilename(serverFilename) &&
!migrationMap.values.contains(serverFilename)
) {
migrationMap[serverFilename] = canonicalName
break
}
}
}
return migrationMap
}
}

View File

@@ -5,6 +5,8 @@ import android.content.Context
import android.graphics.Bitmap
import android.graphics.BitmapFactory
import android.net.Uri
import android.provider.MediaStore
import android.util.Log
import androidx.core.graphics.scale
import java.io.File
import java.io.FileOutputStream
@@ -17,7 +19,7 @@ object ImageUtils {
private const val IMAGE_QUALITY = 85
// Creates the images directory if it doesn't exist
private fun getImagesDirectory(context: Context): File {
fun getImagesDirectory(context: Context): File {
val imagesDir = File(context.filesDir, IMAGES_DIR)
if (!imagesDir.exists()) {
imagesDir.mkdirs()
@@ -43,12 +45,12 @@ object ImageUtils {
val orientedBitmap = correctImageOrientation(context, imageUri, originalBitmap)
val compressedBitmap = compressImage(orientedBitmap)
val filename =
if (problemId != null && imageIndex != null) {
ImageNamingUtils.generateImageFilename(problemId, imageIndex)
} else {
"${UUID.randomUUID()}.jpg"
}
// Always require deterministic naming - no UUID fallback
require(problemId != null && imageIndex != null) {
"Problem ID and image index are required for deterministic image naming"
}
val filename = ImageNamingUtils.generateImageFilename(problemId, imageIndex)
val imageFile = File(getImagesDirectory(context), filename)
FileOutputStream(imageFile).use { output ->
@@ -73,35 +75,35 @@ object ImageUtils {
return try {
val inputStream = context.contentResolver.openInputStream(imageUri)
inputStream?.use { input ->
val exif = android.media.ExifInterface(input)
val exif = androidx.exifinterface.media.ExifInterface(input)
val orientation =
exif.getAttributeInt(
android.media.ExifInterface.TAG_ORIENTATION,
android.media.ExifInterface.ORIENTATION_NORMAL
androidx.exifinterface.media.ExifInterface.TAG_ORIENTATION,
androidx.exifinterface.media.ExifInterface.ORIENTATION_NORMAL
)
val matrix = android.graphics.Matrix()
when (orientation) {
android.media.ExifInterface.ORIENTATION_ROTATE_90 -> {
androidx.exifinterface.media.ExifInterface.ORIENTATION_ROTATE_90 -> {
matrix.postRotate(90f)
}
android.media.ExifInterface.ORIENTATION_ROTATE_180 -> {
androidx.exifinterface.media.ExifInterface.ORIENTATION_ROTATE_180 -> {
matrix.postRotate(180f)
}
android.media.ExifInterface.ORIENTATION_ROTATE_270 -> {
androidx.exifinterface.media.ExifInterface.ORIENTATION_ROTATE_270 -> {
matrix.postRotate(270f)
}
android.media.ExifInterface.ORIENTATION_FLIP_HORIZONTAL -> {
androidx.exifinterface.media.ExifInterface.ORIENTATION_FLIP_HORIZONTAL -> {
matrix.postScale(-1f, 1f)
}
android.media.ExifInterface.ORIENTATION_FLIP_VERTICAL -> {
androidx.exifinterface.media.ExifInterface.ORIENTATION_FLIP_VERTICAL -> {
matrix.postScale(1f, -1f)
}
android.media.ExifInterface.ORIENTATION_TRANSPOSE -> {
androidx.exifinterface.media.ExifInterface.ORIENTATION_TRANSPOSE -> {
matrix.postRotate(90f)
matrix.postScale(-1f, 1f)
}
android.media.ExifInterface.ORIENTATION_TRANSVERSE -> {
androidx.exifinterface.media.ExifInterface.ORIENTATION_TRANSVERSE -> {
matrix.postRotate(-90f)
matrix.postScale(-1f, 1f)
}
@@ -212,6 +214,72 @@ object ImageUtils {
}
}
/** Temporarily saves an image during selection process */
fun saveTemporaryImageFromUri(context: Context, imageUri: Uri): String? {
return try {
val originalBitmap =
MediaStore.Images.Media.getBitmap(context.contentResolver, imageUri)
?: return null
val orientedBitmap = correctImageOrientation(context, imageUri, originalBitmap)
val compressedBitmap = compressImage(orientedBitmap)
val tempFilename = "temp_${UUID.randomUUID()}.jpg"
val imageFile = File(getImagesDirectory(context), tempFilename)
FileOutputStream(imageFile).use { output ->
compressedBitmap.compress(Bitmap.CompressFormat.JPEG, IMAGE_QUALITY, output)
}
originalBitmap.recycle()
if (orientedBitmap != originalBitmap) {
orientedBitmap.recycle()
}
if (compressedBitmap != orientedBitmap) {
compressedBitmap.recycle()
}
tempFilename
} catch (e: Exception) {
Log.e("ImageUtils", "Error saving temporary image from URI", e)
null
}
}
/** Renames a temporary image */
fun renameTemporaryImage(
context: Context,
tempFilename: String,
problemId: String,
imageIndex: Int
): String? {
return try {
val tempFile = File(getImagesDirectory(context), tempFilename)
if (!tempFile.exists()) {
Log.e("ImageUtils", "Temporary file does not exist: $tempFilename")
return null
}
val deterministicFilename =
ImageNamingUtils.generateImageFilename(problemId, imageIndex)
val finalFile = File(getImagesDirectory(context), deterministicFilename)
if (tempFile.renameTo(finalFile)) {
Log.d(
"ImageUtils",
"Renamed temporary image: $tempFilename -> $deterministicFilename"
)
deterministicFilename
} else {
Log.e("ImageUtils", "Failed to rename temporary image: $tempFilename")
null
}
} catch (e: Exception) {
Log.e("ImageUtils", "Error renaming temporary image", e)
null
}
}
/** Saves an image from byte array to app's private storage */
fun saveImageFromBytes(context: Context, imageData: ByteArray): String? {
return try {

View File

@@ -19,6 +19,7 @@ kotlinxSerialization = "1.9.0"
kotlinxCoroutines = "1.10.2"
coil = "2.7.0"
ksp = "2.2.20-2.0.3"
exifinterface = "1.3.6"
[libraries]
androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" }
@@ -66,6 +67,7 @@ mockk = { group = "io.mockk", name = "mockk", version = "1.14.6" }
# Image Loading
coil-compose = { group = "io.coil-kt", name = "coil-compose", version.ref = "coil" }
androidx-exifinterface = { group = "androidx.exifinterface", name = "exifinterface", version.ref = "exifinterface" }
[plugins]
android-application = { id = "com.android.application", version.ref = "agp" }

View File

@@ -465,7 +465,7 @@
CODE_SIGN_ENTITLEMENTS = OpenClimb/OpenClimb.entitlements;
CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 22;
CURRENT_PROJECT_VERSION = 23;
DEVELOPMENT_TEAM = 4BC9Y2LL4B;
DRIVERKIT_DEPLOYMENT_TARGET = 24.6;
ENABLE_PREVIEWS = YES;
@@ -513,7 +513,7 @@
CODE_SIGN_ENTITLEMENTS = OpenClimb/OpenClimb.entitlements;
CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 22;
CURRENT_PROJECT_VERSION = 23;
DEVELOPMENT_TEAM = 4BC9Y2LL4B;
DRIVERKIT_DEPLOYMENT_TARGET = 24.6;
ENABLE_PREVIEWS = YES;
@@ -602,7 +602,7 @@
ASSETCATALOG_COMPILER_WIDGET_BACKGROUND_COLOR_NAME = WidgetBackground;
CODE_SIGN_ENTITLEMENTS = SessionStatusLiveExtension.entitlements;
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 22;
CURRENT_PROJECT_VERSION = 23;
DEVELOPMENT_TEAM = 4BC9Y2LL4B;
GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_FILE = SessionStatusLive/Info.plist;
@@ -632,7 +632,7 @@
ASSETCATALOG_COMPILER_WIDGET_BACKGROUND_COLOR_NAME = WidgetBackground;
CODE_SIGN_ENTITLEMENTS = SessionStatusLiveExtension.entitlements;
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 22;
CURRENT_PROJECT_VERSION = 23;
DEVELOPMENT_TEAM = 4BC9Y2LL4B;
GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_FILE = SessionStatusLive/Info.plist;

View File

@@ -44,8 +44,10 @@ struct PhotoOptionSheet: View {
.buttonStyle(PlainButtonStyle())
Button(action: {
onCameraSelected()
onDismiss()
DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) {
onCameraSelected()
}
}) {
HStack {
Image(systemName: "camera.fill")

View File

@@ -11,6 +11,9 @@ class SyncService: ObservableObject {
@Published var isTesting = false
private let userDefaults = UserDefaults.standard
private var syncTask: Task<Void, Never>?
private var pendingChanges = false
private let syncDebounceDelay: TimeInterval = 2.0
private enum Keys {
static let serverURL = "sync_server_url"
@@ -44,6 +47,11 @@ class SyncService: ObservableObject {
self.lastSyncTime = lastSync
}
self.isConnected = userDefaults.bool(forKey: Keys.isConnected)
// Perform image naming migration on initialization
Task {
await performImageNamingMigration()
}
}
func downloadData() async throws -> ClimbDataBackup {
@@ -144,6 +152,9 @@ class SyncService: ObservableObject {
request.setValue("application/octet-stream", forHTTPHeaderField: "Content-Type")
request.httpBody = imageData
request.timeoutInterval = 60.0
request.cachePolicy = .reloadIgnoringLocalCacheData
let (_, response) = try await URLSession.shared.data(for: request)
guard let httpResponse = response as? HTTPURLResponse else {
@@ -173,6 +184,9 @@ class SyncService: ObservableObject {
request.httpMethod = "GET"
request.setValue("Bearer \(authToken)", forHTTPHeaderField: "Authorization")
request.timeoutInterval = 45.0
request.cachePolicy = .returnCacheDataElseLoad
let (data, response) = try await URLSession.shared.data(for: request)
guard let httpResponse = response as? HTTPURLResponse else {
@@ -283,7 +297,6 @@ class SyncService: ObservableObject {
{
var imagePathMapping: [String: String] = [:]
// Process images by problem to maintain consistent naming
for problem in backup.problems {
guard let imagePaths = problem.imagePaths, !imagePaths.isEmpty else { continue }
@@ -293,19 +306,13 @@ class SyncService: ObservableObject {
do {
let imageData = try await downloadImage(filename: serverFilename)
// Generate consistent filename if needed
let consistentFilename =
ImageNamingUtils.isValidImageFilename(serverFilename)
? serverFilename
: ImageNamingUtils.generateImageFilename(
problemId: problem.id, imageIndex: index)
let consistentFilename = ImageNamingUtils.generateImageFilename(
problemId: problem.id, imageIndex: index)
// Save image with consistent filename
let imageManager = ImageManager.shared
_ = try imageManager.saveImportedImage(
imageData, filename: consistentFilename)
// Map server filename to consistent local filename
imagePathMapping[serverFilename] = consistentFilename
print("Downloaded and mapped image: \(serverFilename) -> \(consistentFilename)")
} catch SyncError.imageNotFound {
@@ -329,12 +336,8 @@ class SyncService: ObservableObject {
for (index, imagePath) in problem.imagePaths.enumerated() {
let filename = URL(fileURLWithPath: imagePath).lastPathComponent
// Ensure filename follows consistent naming convention
let consistentFilename =
ImageNamingUtils.isValidImageFilename(filename)
? filename
: ImageNamingUtils.generateImageFilename(
problemId: problem.id.uuidString, imageIndex: index)
let consistentFilename = ImageNamingUtils.generateImageFilename(
problemId: problem.id.uuidString, imageIndex: index)
// Load image data
let imageManager = ImageManager.shared
@@ -392,6 +395,53 @@ class SyncService: ObservableObject {
)
}
func createBackupForExport(_ dataManager: ClimbingDataManager) -> ClimbDataBackup {
// Filter out active sessions and their attempts from sync
let completedSessions = dataManager.sessions.filter { $0.status != .active }
let activeSessionIds = Set(
dataManager.sessions.filter { $0.status == .active }.map { $0.id })
let completedAttempts = dataManager.attempts.filter {
!activeSessionIds.contains($0.sessionId)
}
// Create backup with normalized image paths for export
return ClimbDataBackup(
exportedAt: DataStateManager.shared.getLastModified(),
gyms: dataManager.gyms.map { BackupGym(from: $0) },
problems: dataManager.problems.map { problem in
var backupProblem = BackupProblem(from: problem)
if !problem.imagePaths.isEmpty {
let normalizedPaths = problem.imagePaths.enumerated().map { index, _ in
ImageNamingUtils.generateImageFilename(
problemId: problem.id.uuidString, imageIndex: index)
}
backupProblem = BackupProblem(
id: backupProblem.id,
gymId: backupProblem.gymId,
name: backupProblem.name,
description: backupProblem.description,
climbType: backupProblem.climbType,
difficulty: backupProblem.difficulty,
tags: backupProblem.tags,
location: backupProblem.location,
imagePaths: normalizedPaths,
isActive: backupProblem.isActive,
dateSet: backupProblem.dateSet,
notes: backupProblem.notes,
createdAt: backupProblem.createdAt,
updatedAt: backupProblem.updatedAt
)
}
return backupProblem
},
sessions: completedSessions.map { BackupClimbSession(from: $0) },
attempts: completedAttempts.map { BackupAttempt(from: $0) },
deletedItems: dataManager.getDeletedItems()
)
}
private func mergeDataSafely(
localBackup: ClimbDataBackup,
serverBackup: ClimbDataBackup,
@@ -620,17 +670,31 @@ class SyncService: ObservableObject {
}
let jsonData = try encoder.encode(backup)
// Collect all downloaded images from ImageManager
// Collect all images from ImageManager
let imageManager = ImageManager.shared
var imageFiles: [(filename: String, data: Data)] = []
let imagePaths = Set(backup.problems.flatMap { $0.imagePaths ?? [] })
for imagePath in imagePaths {
let filename = URL(fileURLWithPath: imagePath).lastPathComponent
let fullPath = imageManager.imagesDirectory.appendingPathComponent(filename).path
if let imageData = imageManager.loadImageData(fromPath: fullPath) {
imageFiles.append((filename: filename, data: imageData))
// Get original problems to access actual image paths on disk
if let problemsData = userDefaults.data(forKey: "problems"),
let problems = try? JSONDecoder().decode([Problem].self, from: problemsData)
{
// Create a mapping from normalized paths to actual paths
for problem in problems {
for (index, imagePath) in problem.imagePaths.enumerated() {
// Get the actual filename on disk
let actualFilename = URL(fileURLWithPath: imagePath).lastPathComponent
let fullPath = imageManager.imagesDirectory.appendingPathComponent(
actualFilename
).path
// Generate the normalized filename for the ZIP
let normalizedFilename = ImageNamingUtils.generateImageFilename(
problemId: problem.id.uuidString, imageIndex: index)
if let imageData = imageManager.loadImageData(fromPath: fullPath) {
imageFiles.append((filename: normalizedFilename, data: imageData))
}
}
}
}
@@ -875,20 +939,51 @@ class SyncService: ObservableObject {
}
func triggerAutoSync(dataManager: ClimbingDataManager) {
// 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 {
if isSyncing {
pendingChanges = true
return
}
syncTask?.cancel()
syncTask = Task {
try? await Task.sleep(nanoseconds: UInt64(syncDebounceDelay * 1_000_000_000))
guard !Task.isCancelled else { return }
repeat {
pendingChanges = false
do {
try await syncWithServer(dataManager: dataManager)
} catch {
await MainActor.run {
self.isSyncing = false
}
return
}
if pendingChanges {
try? await Task.sleep(nanoseconds: UInt64(syncDebounceDelay * 1_000_000_000))
}
} while pendingChanges && !Task.isCancelled
}
}
func forceSyncNow(dataManager: ClimbingDataManager) {
guard isConnected && isConfigured else { return }
syncTask?.cancel()
syncTask = nil
pendingChanges = false
Task {
do {
try await syncWithServer(dataManager: dataManager)
@@ -901,6 +996,10 @@ class SyncService: ObservableObject {
}
func disconnect() {
syncTask?.cancel()
syncTask = nil
pendingChanges = false
isSyncing = false
isConnected = false
lastSyncTime = nil
syncError = nil
@@ -917,6 +1016,112 @@ class SyncService: ObservableObject {
userDefaults.removeObject(forKey: Keys.lastSyncTime)
userDefaults.removeObject(forKey: Keys.isConnected)
userDefaults.removeObject(forKey: Keys.autoSyncEnabled)
syncTask?.cancel()
syncTask = nil
pendingChanges = false
}
deinit {
syncTask?.cancel()
}
// MARK: - Image Naming Migration
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
@@ -926,13 +1131,14 @@ class SyncService: ObservableObject {
var merged = local
let deletedGymIds = Set(deletedItems.filter { $0.type == "gym" }.map { $0.id })
// Remove items that were deleted on other devices
let localGymIds = Set(local.map { $0.id.uuidString })
merged.removeAll { deletedGymIds.contains($0.id.uuidString) }
// Add new items from server (excluding deleted ones)
for serverGym in server {
if let serverGymConverted = try? serverGym.toGym() {
let localHasGym = local.contains(where: { $0.id.uuidString == serverGym.id })
let localHasGym = localGymIds.contains(serverGym.id)
let isDeleted = deletedGymIds.contains(serverGym.id)
if !localHasGym && !isDeleted {
@@ -953,41 +1159,44 @@ class SyncService: ObservableObject {
var merged = local
let deletedProblemIds = Set(deletedItems.filter { $0.type == "problem" }.map { $0.id })
// Remove items that were deleted on other devices
let localProblemIds = Set(local.map { $0.id.uuidString })
merged.removeAll { deletedProblemIds.contains($0.id.uuidString) }
// Add new items from server (excluding deleted ones)
for serverProblem in server {
var problemToAdd = serverProblem
let localHasProblem = localProblemIds.contains(serverProblem.id)
let isDeleted = deletedProblemIds.contains(serverProblem.id)
// Update image paths if needed
if !imagePathMapping.isEmpty {
let updatedImagePaths = serverProblem.imagePaths?.compactMap { oldPath in
imagePathMapping[oldPath] ?? oldPath
if !localHasProblem && !isDeleted {
var problemToAdd = serverProblem
if !imagePathMapping.isEmpty, let imagePaths = serverProblem.imagePaths,
!imagePaths.isEmpty
{
let updatedImagePaths = imagePaths.compactMap { oldPath in
imagePathMapping[oldPath] ?? oldPath
}
if updatedImagePaths != imagePaths {
problemToAdd = BackupProblem(
id: serverProblem.id,
gymId: serverProblem.gymId,
name: serverProblem.name,
description: serverProblem.description,
climbType: serverProblem.climbType,
difficulty: serverProblem.difficulty,
tags: serverProblem.tags,
location: serverProblem.location,
imagePaths: updatedImagePaths,
isActive: serverProblem.isActive,
dateSet: serverProblem.dateSet,
notes: serverProblem.notes,
createdAt: serverProblem.createdAt,
updatedAt: serverProblem.updatedAt
)
}
}
problemToAdd = BackupProblem(
id: serverProblem.id,
gymId: serverProblem.gymId,
name: serverProblem.name,
description: serverProblem.description,
climbType: serverProblem.climbType,
difficulty: serverProblem.difficulty,
tags: serverProblem.tags,
location: serverProblem.location,
imagePaths: updatedImagePaths,
isActive: serverProblem.isActive,
dateSet: serverProblem.dateSet,
notes: serverProblem.notes,
createdAt: serverProblem.createdAt,
updatedAt: serverProblem.updatedAt
)
}
if let serverProblemConverted = try? problemToAdd.toProblem() {
let localHasProblem = local.contains(where: { $0.id.uuidString == problemToAdd.id })
let isDeleted = deletedProblemIds.contains(problemToAdd.id)
if !localHasProblem && !isDeleted {
if let serverProblemConverted = try? problemToAdd.toProblem() {
merged.append(serverProblemConverted)
}
}
@@ -1004,19 +1213,18 @@ class SyncService: ObservableObject {
var merged = local
let deletedSessionIds = Set(deletedItems.filter { $0.type == "session" }.map { $0.id })
// Remove items that were deleted on other devices (but never remove active sessions)
let localSessionIds = Set(local.map { $0.id.uuidString })
merged.removeAll { session in
deletedSessionIds.contains(session.id.uuidString) && session.status != .active
}
// Add new items from server (excluding deleted ones)
for serverSession in server {
if let serverSessionConverted = try? serverSession.toClimbSession() {
let localHasSession = local.contains(where: { $0.id.uuidString == serverSession.id }
)
let isDeleted = deletedSessionIds.contains(serverSession.id)
let localHasSession = localSessionIds.contains(serverSession.id)
let isDeleted = deletedSessionIds.contains(serverSession.id)
if !localHasSession && !isDeleted {
if !localHasSession && !isDeleted {
if let serverSessionConverted = try? serverSession.toClimbSession() {
merged.append(serverSessionConverted)
}
}
@@ -1031,6 +1239,8 @@ class SyncService: ObservableObject {
var merged = local
let deletedAttemptIds = Set(deletedItems.filter { $0.type == "attempt" }.map { $0.id })
let localAttemptIds = Set(local.map { $0.id.uuidString })
// Get active session IDs to protect their attempts
let activeSessionIds = Set(
local.compactMap { attempt in
@@ -1048,14 +1258,12 @@ class SyncService: ObservableObject {
&& !activeSessionIds.contains(attempt.sessionId)
}
// Add new items from server (excluding deleted ones)
for serverAttempt in server {
if let serverAttemptConverted = try? serverAttempt.toAttempt() {
let localHasAttempt = local.contains(where: { $0.id.uuidString == serverAttempt.id }
)
let isDeleted = deletedAttemptIds.contains(serverAttempt.id)
let localHasAttempt = localAttemptIds.contains(serverAttempt.id)
let isDeleted = deletedAttemptIds.contains(serverAttempt.id)
if !localHasAttempt && !isDeleted {
if !localHasAttempt && !isDeleted {
if let serverAttemptConverted = try? serverAttempt.toAttempt() {
merged.append(serverAttemptConverted)
}
}

View File

@@ -852,4 +852,73 @@ class ImageManager {
print("ERROR: Failed to migrate from previous Application Support: \(error)")
}
}
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

@@ -11,21 +11,18 @@ class ImageNamingUtils {
private static let hashLength = 12
/// Generates a deterministic filename for a problem image
static func generateImageFilename(problemId: String, timestamp: String, imageIndex: Int)
-> String
{
let input = "\(problemId)_\(timestamp)_\(imageIndex)"
static func generateImageFilename(problemId: String, imageIndex: Int) -> String {
let input = "\(problemId)_\(imageIndex)"
let hash = createHash(from: input)
return "problem_\(hash)_\(imageIndex)\(imageExtension)"
}
/// Generates a deterministic filename using current timestamp
static func generateImageFilename(problemId: String, imageIndex: Int) -> String {
let timestamp = ISO8601DateFormatter().string(from: Date())
return generateImageFilename(
problemId: problemId, timestamp: timestamp, imageIndex: imageIndex)
/// Legacy method for backward compatibility
static func generateImageFilename(problemId: String, timestamp: String, imageIndex: Int)
-> String
{
return generateImageFilename(problemId: problemId, imageIndex: imageIndex)
}
/// Extracts problem ID from an image filename
@@ -64,9 +61,7 @@ class ImageNamingUtils {
return oldFilename
}
let timestamp = ISO8601DateFormatter().string(from: Date())
return generateImageFilename(
problemId: problemId, timestamp: timestamp, imageIndex: imageIndex)
return generateImageFilename(problemId: problemId, imageIndex: imageIndex)
}
/// Creates a deterministic hash from input string
@@ -84,8 +79,7 @@ class ImageNamingUtils {
var renameMap: [String: String] = [:]
for (index, oldFilename) in existingFilenames.enumerated() {
let newFilename = migrateFilename(
oldFilename: oldFilename, problemId: problemId, imageIndex: index)
let newFilename = generateImageFilename(problemId: problemId, imageIndex: index)
if newFilename != oldFilename {
renameMap[oldFilename] = newFilename
}
@@ -113,6 +107,40 @@ class ImageNamingUtils {
invalidImages: invalidImages
)
}
/// Generates the canonical filename that should be used for a problem image
static func getCanonicalImageFilename(problemId: String, imageIndex: Int) -> String {
return generateImageFilename(problemId: problemId, imageIndex: imageIndex)
}
/// Creates a mapping of existing server filenames to canonical filenames
static func createServerMigrationMap(
problemId: String,
serverImageFilenames: [String],
localImageCount: Int
) -> [String: String] {
var migrationMap: [String: String] = [:]
for imageIndex in 0..<localImageCount {
let canonicalName = getCanonicalImageFilename(
problemId: problemId, imageIndex: imageIndex)
if serverImageFilenames.contains(canonicalName) {
continue
}
for serverFilename in serverImageFilenames {
if isValidImageFilename(serverFilename)
&& !migrationMap.values.contains(serverFilename)
{
migrationMap[serverFilename] = canonicalName
break
}
}
}
return migrationMap
}
}
// Result of image filename validation

View File

@@ -29,6 +29,7 @@ class ClimbingDataManager: ObservableObject {
private let encoder = JSONEncoder()
private let decoder = JSONDecoder()
nonisolated(unsafe) private var liveActivityObserver: NSObjectProtocol?
nonisolated(unsafe) private var migrationObserver: NSObjectProtocol?
let syncService = SyncService()
let healthKitService = HealthKitService.shared
@@ -68,8 +69,8 @@ class ClimbingDataManager: ObservableObject {
init() {
_ = ImageManager.shared
loadAllData()
migrateImagePaths()
setupLiveActivityNotifications()
setupMigrationNotifications()
// Keep our published isSyncing in sync with syncService.isSyncing
syncService.$isSyncing
@@ -88,6 +89,9 @@ class ClimbingDataManager: ObservableObject {
if let observer = liveActivityObserver {
NotificationCenter.default.removeObserver(observer)
}
if let observer = migrationObserver {
NotificationCenter.default.removeObserver(observer)
}
}
private func loadAllData() {
@@ -632,7 +636,7 @@ class ClimbingDataManager: ObservableObject {
saveAttempts()
let removedCount = initialAttemptCount - attempts.count
print(
"Cleanup complete. Removed \(removedCount) attempts. Remaining: \(attempts.count)"
"Cleanup complete. Removed \(removedCount) attempts. Remaining: \(attempts.count)"
)
}
@@ -707,9 +711,9 @@ class ClimbingDataManager: ObservableObject {
report += "Orphaned Sessions: \(orphanedSessions.count)\n"
if orphanedAttempts.isEmpty && orphanedProblems.isEmpty && orphanedSessions.isEmpty {
report += "\nNo integrity issues found"
report += "\nNo integrity issues found"
} else {
report += "\n⚠️ Issues found - run cleanup to fix"
report += "\nIssues found - run cleanup to fix"
}
return report
@@ -749,6 +753,7 @@ class ClimbingDataManager: ObservableObject {
let dateFormatter = DateFormatter()
dateFormatter.dateFormat = "yyyy-MM-dd'T'HH:mm:ss.SSSSSS"
// Create export data with normalized image paths
let exportData = ClimbDataBackup(
exportedAt: dateFormatter.string(from: Date()),
version: "2.0",
@@ -759,7 +764,7 @@ class ClimbingDataManager: ObservableObject {
attempts: attempts.map { BackupAttempt(from: $0) }
)
// Collect referenced image paths
// Collect actual image paths from disk for the ZIP
let referencedImagePaths = collectReferencedImagePaths()
print("Starting export with \(referencedImagePaths.count) images")
@@ -878,17 +883,19 @@ extension ClimbingDataManager {
"Problem '\(problem.name ?? "Unnamed")' has \(problem.imagePaths.count) images"
)
for imagePath in problem.imagePaths {
print(" - Relative path: \(imagePath)")
let fullPath = ImageManager.shared.getFullPath(from: imagePath)
print(" - Full path: \(fullPath)")
print(" - Stored path: \(imagePath)")
// Extract just the filename (migration should have normalized these)
let filename = URL(fileURLWithPath: imagePath).lastPathComponent
let fullPath = ImageManager.shared.getFullPath(from: filename)
print(" - Full disk path: \(fullPath)")
// Check if file exists
if FileManager.default.fileExists(atPath: fullPath) {
print(" File exists")
print(" File exists")
imagePaths.insert(fullPath)
} else {
print(" File does NOT exist")
// Still add it to let ZipUtils handle the error logging
print(" ✗ WARNING: File not found at \(fullPath)")
// Still add it to let ZipUtils handle the logging
imagePaths.insert(fullPath)
}
}
@@ -904,11 +911,53 @@ extension ClimbingDataManager {
imagePathMapping: [String: String]
) -> [BackupProblem] {
return problems.map { problem in
let updatedImagePaths = (problem.imagePaths ?? []).compactMap { oldPath in
let fileName = URL(fileURLWithPath: oldPath).lastPathComponent
return imagePathMapping[fileName]
guard let originalImagePaths = problem.imagePaths, !originalImagePaths.isEmpty else {
return problem
}
return problem.withUpdatedImagePaths(updatedImagePaths)
var deterministicImagePaths: [String] = []
for (index, oldPath) in originalImagePaths.enumerated() {
let originalFileName = URL(fileURLWithPath: oldPath).lastPathComponent
let deterministicName = ImageNamingUtils.generateImageFilename(
problemId: problem.id, imageIndex: index)
if let tempFileName = imagePathMapping[originalFileName] {
let imageManager = ImageManager.shared
let tempPath = imageManager.imagesDirectory.appendingPathComponent(tempFileName)
let deterministicPath = imageManager.imagesDirectory.appendingPathComponent(
deterministicName)
do {
if FileManager.default.fileExists(atPath: tempPath.path) {
try FileManager.default.moveItem(at: tempPath, to: deterministicPath)
let tempBackupPath = imageManager.backupDirectory
.appendingPathComponent(tempFileName)
let deterministicBackupPath = imageManager.backupDirectory
.appendingPathComponent(deterministicName)
if FileManager.default.fileExists(atPath: tempBackupPath.path) {
try? FileManager.default.moveItem(
at: tempBackupPath, to: deterministicBackupPath)
}
deterministicImagePaths.append(deterministicName)
print("Renamed imported image: \(tempFileName)\(deterministicName)")
}
} catch {
print(
"Failed to rename imported image \(tempFileName) to \(deterministicName): \(error)"
)
deterministicImagePaths.append(tempFileName)
}
} else {
deterministicImagePaths.append(deterministicName)
}
}
return problem.withUpdatedImagePaths(deterministicImagePaths)
}
}
@@ -1134,6 +1183,19 @@ extension ClimbingDataManager {
}
}
private func setupMigrationNotifications() {
migrationObserver = NotificationCenter.default.addObserver(
forName: NSNotification.Name("ImageMigrationCompleted"),
object: nil,
queue: .main
) { [weak self] notification in
if let updateCount = notification.userInfo?["updateCount"] as? Int {
print("🔔 Image migration completed with \(updateCount) updates - reloading data")
self?.loadProblems()
}
}
}
/// Handle Live Activity being dismissed by user
private func handleLiveActivityDismissed() async {
guard let activeSession = activeSession,

View File

@@ -458,24 +458,36 @@ struct AddAttemptView: View {
let difficulty = DifficultyGrade(
system: selectedDifficultySystem, grade: newProblemGrade)
// Save images and get paths
var imagePaths: [String] = []
for data in imageData {
if let relativePath = ImageManager.shared.saveImageData(data) {
imagePaths.append(relativePath)
}
}
let newProblem = Problem(
gymId: gym.id,
name: newProblemName.isEmpty ? nil : newProblemName,
climbType: selectedClimbType,
difficulty: difficulty,
imagePaths: imagePaths
imagePaths: []
)
dataManager.addProblem(newProblem)
if !imageData.isEmpty {
var imagePaths: [String] = []
for (index, data) in imageData.enumerated() {
let deterministicName = ImageNamingUtils.generateImageFilename(
problemId: newProblem.id.uuidString, imageIndex: index)
if let relativePath = ImageManager.shared.saveImageData(
data, withName: deterministicName)
{
imagePaths.append(relativePath)
}
}
if !imagePaths.isEmpty {
let updatedProblem = newProblem.updated(imagePaths: imagePaths)
dataManager.updateProblem(updatedProblem)
}
}
let attempt = Attempt(
sessionId: session.id,
problemId: newProblem.id,
@@ -1218,24 +1230,36 @@ struct EditAttemptView: View {
let difficulty = DifficultyGrade(
system: selectedDifficultySystem, grade: newProblemGrade)
// Save images and get paths
var imagePaths: [String] = []
for data in imageData {
if let relativePath = ImageManager.shared.saveImageData(data) {
imagePaths.append(relativePath)
}
}
let newProblem = Problem(
gymId: gym.id,
name: newProblemName.isEmpty ? nil : newProblemName,
climbType: selectedClimbType,
difficulty: difficulty,
imagePaths: imagePaths
imagePaths: []
)
dataManager.addProblem(newProblem)
if !imageData.isEmpty {
var imagePaths: [String] = []
for (index, data) in imageData.enumerated() {
let deterministicName = ImageNamingUtils.generateImageFilename(
problemId: newProblem.id.uuidString, imageIndex: index)
if let relativePath = ImageManager.shared.saveImageData(
data, withName: deterministicName)
{
imagePaths.append(relativePath)
}
}
if !imagePaths.isEmpty {
let updatedProblem = newProblem.updated(imagePaths: imagePaths)
dataManager.updateProblem(updatedProblem)
}
}
let updatedAttempt = attempt.updated(
problemId: newProblem.id,
result: selectedResult,
@@ -1329,16 +1353,18 @@ struct ProblemSelectionImageView: View {
return
}
DispatchQueue.global(qos: .userInitiated).async {
if let data = ImageManager.shared.loadImageData(fromPath: imagePath),
let image = UIImage(data: data)
{
DispatchQueue.main.async {
Task {
let data = await MainActor.run {
ImageManager.shared.loadImageData(fromPath: imagePath)
}
if let data = data, let image = UIImage(data: data) {
await MainActor.run {
self.uiImage = image
self.isLoading = false
}
} else {
DispatchQueue.main.async {
await MainActor.run {
self.hasFailed = true
self.isLoading = false
}

View File

@@ -556,21 +556,25 @@ struct AddEditProblemView: View {
let difficulty = DifficultyGrade(system: selectedDifficultySystem, grade: difficultyGrade)
// Save new image data and combine with existing paths
var allImagePaths = imagePaths
if isEditing, let problem = existingProblem {
var allImagePaths = imagePaths
// Only save NEW images (those beyond the existing imagePaths count)
let newImagesStartIndex = imagePaths.count
if imageData.count > newImagesStartIndex {
for i in newImagesStartIndex..<imageData.count {
let data = imageData[i]
if let relativePath = ImageManager.shared.saveImageData(data) {
allImagePaths.append(relativePath)
let newImagesStartIndex = imagePaths.count
if imageData.count > newImagesStartIndex {
for i in newImagesStartIndex..<imageData.count {
let data = imageData[i]
let imageIndex = allImagePaths.count
let deterministicName = ImageNamingUtils.generateImageFilename(
problemId: problem.id.uuidString, imageIndex: imageIndex)
if let relativePath = ImageManager.shared.saveImageData(
data, withName: deterministicName)
{
allImagePaths.append(relativePath)
}
}
}
}
if isEditing, let problem = existingProblem {
let updatedProblem = problem.updated(
name: trimmedName.isEmpty ? nil : trimmedName,
description: trimmedDescription.isEmpty ? nil : trimmedDescription,
@@ -595,11 +599,32 @@ struct AddEditProblemView: View {
tags: trimmedTags,
location: trimmedLocation.isEmpty ? nil : trimmedLocation,
imagePaths: allImagePaths,
imagePaths: [],
dateSet: dateSet,
notes: trimmedNotes.isEmpty ? nil : trimmedNotes
)
dataManager.addProblem(newProblem)
if !imageData.isEmpty {
var imagePaths: [String] = []
for (index, data) in imageData.enumerated() {
let deterministicName = ImageNamingUtils.generateImageFilename(
problemId: newProblem.id.uuidString, imageIndex: index)
if let relativePath = ImageManager.shared.saveImageData(
data, withName: deterministicName)
{
imagePaths.append(relativePath)
}
}
if !imagePaths.isEmpty {
let updatedProblem = newProblem.updated(imagePaths: imagePaths)
dataManager.updateProblem(updatedProblem)
}
}
}
dismiss()

View File

@@ -486,16 +486,18 @@ struct ProblemDetailImageView: View {
return
}
DispatchQueue.global(qos: .userInitiated).async {
if let data = ImageManager.shared.loadImageData(fromPath: imagePath),
let image = UIImage(data: data)
{
DispatchQueue.main.async {
Task {
let data = await MainActor.run {
ImageManager.shared.loadImageData(fromPath: imagePath)
}
if let data = data, let image = UIImage(data: data) {
await MainActor.run {
self.uiImage = image
self.isLoading = false
}
} else {
DispatchQueue.main.async {
await MainActor.run {
self.hasFailed = true
self.isLoading = false
}
@@ -550,16 +552,18 @@ struct ProblemDetailImageFullView: View {
return
}
DispatchQueue.global(qos: .userInitiated).async {
if let data = ImageManager.shared.loadImageData(fromPath: imagePath),
let image = UIImage(data: data)
{
DispatchQueue.main.async {
Task {
let data = await MainActor.run {
ImageManager.shared.loadImageData(fromPath: imagePath)
}
if let data = data, let image = UIImage(data: data) {
await MainActor.run {
self.uiImage = image
self.isLoading = false
}
} else {
DispatchQueue.main.async {
await MainActor.run {
self.hasFailed = true
self.isLoading = false
}

View File

@@ -484,7 +484,7 @@ struct ProblemImageView: View {
@State private var isLoading = true
@State private var hasFailed = false
private static var imageCache: NSCache<NSString, UIImage> = {
private static let imageCache: NSCache<NSString, UIImage> = {
let cache = NSCache<NSString, UIImage>()
cache.countLimit = 100
cache.totalCostLimit = 50 * 1024 * 1024 // 50MB
@@ -531,31 +531,28 @@ struct ProblemImageView: View {
return
}
let cacheKey = NSString(string: imagePath)
// Load image asynchronously
Task { @MainActor in
let cacheKey = NSString(string: imagePath)
// Check cache first
if let cachedImage = Self.imageCache.object(forKey: cacheKey) {
self.uiImage = cachedImage
self.isLoading = false
return
}
// Check cache first
if let cachedImage = Self.imageCache.object(forKey: cacheKey) {
self.uiImage = cachedImage
self.isLoading = false
return
}
DispatchQueue.global(qos: .userInitiated).async {
// Load image data
if let data = ImageManager.shared.loadImageData(fromPath: imagePath),
let image = UIImage(data: data)
{
// Cache the image
Self.imageCache.setObject(image, forKey: cacheKey)
DispatchQueue.main.async {
self.uiImage = image
self.isLoading = false
}
self.uiImage = image
self.isLoading = false
} else {
DispatchQueue.main.async {
self.hasFailed = true
self.isLoading = false
}
self.hasFailed = true
self.isLoading = false
}
}
}

View File

@@ -80,6 +80,10 @@ struct DataManagementSection: View {
@Binding var activeSheet: SheetType?
@State private var showingResetAlert = false
@State private var isExporting = false
@State private var isMigrating = false
@State private var showingMigrationAlert = false
@State private var isDeletingImages = false
@State private var showingDeleteImagesAlert = false
var body: some View {
Section("Data Management") {
@@ -117,6 +121,48 @@ struct DataManagementSection: View {
}
.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
Button(action: {
showingDeleteImagesAlert = true
}) {
HStack {
if isDeletingImages {
ProgressView()
.scaleEffect(0.8)
Text("Deleting Images...")
.foregroundColor(.secondary)
} else {
Image(systemName: "trash")
.foregroundColor(.red)
Text("Delete All Images")
}
Spacer()
}
}
.disabled(isDeletingImages)
.foregroundColor(.red)
// Reset All Data
Button(action: {
showingResetAlert = true
@@ -140,6 +186,26 @@ 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."
)
}
.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) {
Button("Cancel", role: .cancel) {}
Button("Delete", role: .destructive) {
deleteAllImages()
}
} message: {
Text(
"This will permanently delete ALL image files from your device.\n\nProblems will keep their references but the actual image files will be removed. This cannot be undone.\n\nConsider exporting your data first if you want to keep your images."
)
}
}
private func exportDataAsync() {
@@ -152,6 +218,75 @@ 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() {
isDeletingImages = true
Task {
await MainActor.run {
deleteAllImageFiles()
isDeletingImages = false
dataManager.successMessage = "All images deleted successfully!"
}
}
}
private func deleteAllImageFiles() {
let imageManager = ImageManager.shared
let fileManager = FileManager.default
// Delete all images from the images directory
let imagesDir = imageManager.imagesDirectory
do {
let imageFiles = try fileManager.contentsOfDirectory(
at: imagesDir, includingPropertiesForKeys: nil)
var deletedCount = 0
for imageFile in imageFiles {
do {
try fileManager.removeItem(at: imageFile)
deletedCount += 1
} catch {
print("Failed to delete image: \(imageFile.lastPathComponent)")
}
}
print("Deleted \(deletedCount) image files")
} catch {
print("Failed to access images directory: \(error)")
}
// Delete all images from backup directory
let backupDir = imageManager.backupDirectory
do {
let backupFiles = try fileManager.contentsOfDirectory(
at: backupDir, includingPropertiesForKeys: nil)
for backupFile in backupFiles {
try? fileManager.removeItem(at: backupFile)
}
} catch {
print("Failed to access backup directory: \(error)")
}
// Clear image paths from all problems
let updatedProblems = dataManager.problems.map { problem in
problem.updated(imagePaths: [])
}
for problem in updatedProblems {
dataManager.updateProblem(problem)
}
}
}
struct AppInfoSection: View {