iOS Build 23
This commit is contained in:
@@ -16,7 +16,7 @@ android {
|
|||||||
applicationId = "com.atridad.openclimb"
|
applicationId = "com.atridad.openclimb"
|
||||||
minSdk = 31
|
minSdk = 31
|
||||||
targetSdk = 36
|
targetSdk = 36
|
||||||
versionCode = 35
|
versionCode = 36
|
||||||
versionName = "1.8.0"
|
versionName = "1.8.0"
|
||||||
|
|
||||||
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
||||||
@@ -60,6 +60,7 @@ dependencies {
|
|||||||
// Room Database
|
// Room Database
|
||||||
implementation(libs.androidx.room.runtime)
|
implementation(libs.androidx.room.runtime)
|
||||||
implementation(libs.androidx.room.ktx)
|
implementation(libs.androidx.room.ktx)
|
||||||
|
implementation(libs.androidx.exifinterface)
|
||||||
|
|
||||||
ksp(libs.androidx.room.compiler)
|
ksp(libs.androidx.room.compiler)
|
||||||
|
|
||||||
|
|||||||
@@ -25,6 +25,8 @@ import java.io.IOException
|
|||||||
import java.time.Instant
|
import java.time.Instant
|
||||||
import java.util.concurrent.TimeUnit
|
import java.util.concurrent.TimeUnit
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.Job
|
||||||
|
import kotlinx.coroutines.delay
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
import kotlinx.coroutines.flow.StateFlow
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
import kotlinx.coroutines.flow.asStateFlow
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
@@ -54,9 +56,9 @@ class SyncService(private val context: Context, private val repository: ClimbRep
|
|||||||
|
|
||||||
private val httpClient =
|
private val httpClient =
|
||||||
OkHttpClient.Builder()
|
OkHttpClient.Builder()
|
||||||
.connectTimeout(30, TimeUnit.SECONDS)
|
.connectTimeout(45, TimeUnit.SECONDS)
|
||||||
.readTimeout(60, TimeUnit.SECONDS)
|
.readTimeout(90, TimeUnit.SECONDS)
|
||||||
.writeTimeout(60, TimeUnit.SECONDS)
|
.writeTimeout(90, TimeUnit.SECONDS)
|
||||||
.build()
|
.build()
|
||||||
|
|
||||||
private val json = Json {
|
private val json = Json {
|
||||||
@@ -86,6 +88,11 @@ class SyncService(private val context: Context, private val repository: ClimbRep
|
|||||||
private val _isTesting = MutableStateFlow(false)
|
private val _isTesting = MutableStateFlow(false)
|
||||||
val isTesting: StateFlow<Boolean> = _isTesting.asStateFlow()
|
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
|
// Configuration keys
|
||||||
private object Keys {
|
private object Keys {
|
||||||
const val SERVER_URL = "sync_server_url"
|
const val SERVER_URL = "sync_server_url"
|
||||||
@@ -137,6 +144,9 @@ class SyncService(private val context: Context, private val repository: ClimbRep
|
|||||||
repository.setAutoSyncCallback {
|
repository.setAutoSyncCallback {
|
||||||
kotlinx.coroutines.CoroutineScope(Dispatchers.IO).launch { triggerAutoSync() }
|
kotlinx.coroutines.CoroutineScope(Dispatchers.IO).launch { triggerAutoSync() }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Perform image naming migration on initialization
|
||||||
|
kotlinx.coroutines.CoroutineScope(Dispatchers.IO).launch { performImageNamingMigration() }
|
||||||
}
|
}
|
||||||
|
|
||||||
suspend fun downloadData(): ClimbDataBackup =
|
suspend fun downloadData(): ClimbDataBackup =
|
||||||
@@ -297,6 +307,9 @@ class SyncService(private val context: Context, private val repository: ClimbRep
|
|||||||
try {
|
try {
|
||||||
val response = httpClient.newCall(request).execute()
|
val response = httpClient.newCall(request).execute()
|
||||||
Log.d(TAG, "Image download response for $filename: ${response.code}")
|
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) {
|
when (response.code) {
|
||||||
200 -> {
|
200 -> {
|
||||||
@@ -426,28 +439,23 @@ class SyncService(private val context: Context, private val repository: ClimbRep
|
|||||||
totalImages += imageCount
|
totalImages += imageCount
|
||||||
}
|
}
|
||||||
|
|
||||||
problem.imagePaths?.forEachIndexed { index, imagePath ->
|
problem.imagePaths?.forEach { imagePath ->
|
||||||
try {
|
try {
|
||||||
Log.d(TAG, "Attempting to download image: $imagePath")
|
// Use the server's actual filename, not regenerated
|
||||||
val imageData = downloadImage(imagePath)
|
|
||||||
|
|
||||||
val serverFilename = imagePath.substringAfterLast('/')
|
val serverFilename = imagePath.substringAfterLast('/')
|
||||||
val consistentFilename =
|
|
||||||
if (ImageNamingUtils.isValidImageFilename(serverFilename)) {
|
Log.d(TAG, "Attempting to download image: $serverFilename")
|
||||||
serverFilename
|
val imageData = downloadImage(serverFilename)
|
||||||
} else {
|
|
||||||
ImageNamingUtils.generateImageFilename(problem.id, index)
|
|
||||||
}
|
|
||||||
|
|
||||||
val localImagePath =
|
val localImagePath =
|
||||||
ImageUtils.saveImageFromBytesWithFilename(
|
ImageUtils.saveImageFromBytesWithFilename(
|
||||||
context,
|
context,
|
||||||
imageData,
|
imageData,
|
||||||
consistentFilename
|
serverFilename
|
||||||
)
|
)
|
||||||
|
|
||||||
if (localImagePath != null) {
|
if (localImagePath != null) {
|
||||||
imagePathMapping[serverFilename] = localImagePath
|
imagePathMapping[imagePath] = localImagePath
|
||||||
downloadedImages++
|
downloadedImages++
|
||||||
Log.d(
|
Log.d(
|
||||||
TAG,
|
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")
|
Log.w(TAG, "Failed to save downloaded image locally: $imagePath")
|
||||||
failedImages++
|
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) {
|
} catch (e: Exception) {
|
||||||
Log.w(TAG, "Failed to download image $imagePath: ${e.message}")
|
Log.w(TAG, "Failed to download image $imagePath: ${e.message}")
|
||||||
failedImages++
|
failedImages++
|
||||||
@@ -495,30 +509,22 @@ class SyncService(private val context: Context, private val repository: ClimbRep
|
|||||||
|
|
||||||
if (imageFile.exists() && imageFile.length() > 0) {
|
if (imageFile.exists() && imageFile.length() > 0) {
|
||||||
val imageData = imageFile.readBytes()
|
val imageData = imageFile.readBytes()
|
||||||
|
// Always use consistent problem-based naming for uploads
|
||||||
|
val consistentFilename =
|
||||||
|
ImageNamingUtils.generateImageFilename(problem.id, index)
|
||||||
|
|
||||||
val filename = imagePath.substringAfterLast('/')
|
val filename = imagePath.substringAfterLast('/')
|
||||||
|
|
||||||
val consistentFilename =
|
// Rename local file if needed
|
||||||
if (ImageNamingUtils.isValidImageFilename(filename)) {
|
if (filename != consistentFilename) {
|
||||||
filename
|
val newFile = java.io.File(imageFile.parent, consistentFilename)
|
||||||
} else {
|
|
||||||
val newFilename =
|
|
||||||
ImageNamingUtils.generateImageFilename(
|
|
||||||
problem.id,
|
|
||||||
index
|
|
||||||
)
|
|
||||||
val newFile = java.io.File(imageFile.parent, newFilename)
|
|
||||||
if (imageFile.renameTo(newFile)) {
|
if (imageFile.renameTo(newFile)) {
|
||||||
Log.d(
|
Log.d(
|
||||||
TAG,
|
TAG,
|
||||||
"Renamed local image file: $filename -> $newFilename"
|
"Renamed local image file: $filename -> $consistentFilename"
|
||||||
)
|
)
|
||||||
newFilename
|
|
||||||
} else {
|
} else {
|
||||||
Log.w(
|
Log.w(TAG, "Failed to rename local image file, using original")
|
||||||
TAG,
|
|
||||||
"Failed to rename local image file, using original"
|
|
||||||
)
|
|
||||||
filename
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -568,7 +574,21 @@ class SyncService(private val context: Context, private val repository: ClimbRep
|
|||||||
version = "2.0",
|
version = "2.0",
|
||||||
formatVersion = "2.0",
|
formatVersion = "2.0",
|
||||||
gyms = allGyms.map { BackupGym.fromGym(it) },
|
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) },
|
sessions = completedSessions.map { BackupClimbSession.fromClimbSession(it) },
|
||||||
attempts = completedAttempts.map { BackupAttempt.fromAttempt(it) },
|
attempts = completedAttempts.map { BackupAttempt.fromAttempt(it) },
|
||||||
deletedItems = repository.getDeletedItems()
|
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 localIds = local.map { it.id }.toSet()
|
||||||
val deletedGymIds = deletedItems.filter { it.type == "gym" }.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) }
|
merged.removeAll { deletedGymIds.contains(it.id) }
|
||||||
|
|
||||||
// Add new items from server (excluding deleted ones)
|
|
||||||
server.forEach { serverGym ->
|
server.forEach { serverGym ->
|
||||||
if (!localIds.contains(serverGym.id) && !deletedGymIds.contains(serverGym.id)) {
|
if (!localIds.contains(serverGym.id) && !deletedGymIds.contains(serverGym.id)) {
|
||||||
try {
|
try {
|
||||||
@@ -878,24 +896,26 @@ class SyncService(private val context: Context, private val repository: ClimbRep
|
|||||||
val localIds = local.map { it.id }.toSet()
|
val localIds = local.map { it.id }.toSet()
|
||||||
val deletedProblemIds = deletedItems.filter { it.type == "problem" }.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) }
|
merged.removeAll { deletedProblemIds.contains(it.id) }
|
||||||
|
|
||||||
// Add new items from server (excluding deleted ones)
|
|
||||||
server.forEach { serverProblem ->
|
server.forEach { serverProblem ->
|
||||||
if (!localIds.contains(serverProblem.id) &&
|
if (!localIds.contains(serverProblem.id) &&
|
||||||
!deletedProblemIds.contains(serverProblem.id)
|
!deletedProblemIds.contains(serverProblem.id)
|
||||||
) {
|
) {
|
||||||
try {
|
try {
|
||||||
val problemToAdd =
|
val problemToAdd =
|
||||||
if (imagePathMapping.isNotEmpty()) {
|
if (imagePathMapping.isNotEmpty() &&
|
||||||
val newImagePaths =
|
!serverProblem.imagePaths.isNullOrEmpty()
|
||||||
serverProblem.imagePaths?.map { oldPath ->
|
) {
|
||||||
val filename = oldPath.substringAfterLast('/')
|
val updatedImagePaths =
|
||||||
imagePathMapping[filename] ?: oldPath
|
serverProblem.imagePaths?.mapNotNull { oldPath ->
|
||||||
|
imagePathMapping[oldPath] ?: oldPath
|
||||||
|
}
|
||||||
|
if (updatedImagePaths != serverProblem.imagePaths) {
|
||||||
|
serverProblem.copy(imagePaths = updatedImagePaths)
|
||||||
|
} else {
|
||||||
|
serverProblem
|
||||||
}
|
}
|
||||||
?: emptyList()
|
|
||||||
serverProblem.withUpdatedImagePaths(newImagePaths)
|
|
||||||
} else {
|
} else {
|
||||||
serverProblem
|
serverProblem
|
||||||
}
|
}
|
||||||
@@ -918,10 +938,8 @@ class SyncService(private val context: Context, private val repository: ClimbRep
|
|||||||
val localIds = local.map { it.id }.toSet()
|
val localIds = local.map { it.id }.toSet()
|
||||||
val deletedSessionIds = deletedItems.filter { it.type == "session" }.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 }
|
merged.removeAll { deletedSessionIds.contains(it.id) && it.status != SessionStatus.ACTIVE }
|
||||||
|
|
||||||
// Add new items from server (excluding deleted ones)
|
|
||||||
server.forEach { serverSession ->
|
server.forEach { serverSession ->
|
||||||
if (!localIds.contains(serverSession.id) &&
|
if (!localIds.contains(serverSession.id) &&
|
||||||
!deletedSessionIds.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 localIds = local.map { it.id }.toSet()
|
||||||
val deletedAttemptIds = deletedItems.filter { it.type == "attempt" }.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) }
|
merged.removeAll { deletedAttemptIds.contains(it.id) }
|
||||||
|
|
||||||
// Add new items from server (excluding deleted ones)
|
|
||||||
server.forEach { serverAttempt ->
|
server.forEach { serverAttempt ->
|
||||||
if (!localIds.contains(serverAttempt.id) &&
|
if (!localIds.contains(serverAttempt.id) &&
|
||||||
!deletedAttemptIds.contains(serverAttempt.id)
|
!deletedAttemptIds.contains(serverAttempt.id)
|
||||||
@@ -1093,19 +1109,54 @@ class SyncService(private val context: Context, private val repository: ClimbRep
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (_isSyncing.value) {
|
if (_isSyncing.value) {
|
||||||
Log.d(TAG, "Sync already in progress, skipping auto-sync")
|
pendingChanges = true
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
syncJob?.cancel()
|
||||||
|
|
||||||
|
syncJob =
|
||||||
|
kotlinx.coroutines.CoroutineScope(Dispatchers.IO).launch {
|
||||||
|
delay(syncDebounceDelay)
|
||||||
|
|
||||||
|
do {
|
||||||
|
pendingChanges = false
|
||||||
|
|
||||||
try {
|
try {
|
||||||
syncWithServer()
|
syncWithServer()
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
Log.e(TAG, "Auto-sync failed: ${e.message}")
|
Log.e(TAG, "Auto-sync failed: ${e.message}")
|
||||||
_syncError.value = 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, "Force sync failed: ${e.message}")
|
||||||
|
_syncError.value = e.message
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun clearConfiguration() {
|
fun clearConfiguration() {
|
||||||
|
syncJob?.cancel()
|
||||||
|
syncJob = null
|
||||||
|
pendingChanges = false
|
||||||
|
|
||||||
serverURL = ""
|
serverURL = ""
|
||||||
authToken = ""
|
authToken = ""
|
||||||
isAutoSyncEnabled = true
|
isAutoSyncEnabled = true
|
||||||
@@ -1116,6 +1167,113 @@ class SyncService(private val context: Context, private val repository: ClimbRep
|
|||||||
sharedPreferences.edit().clear().apply()
|
sharedPreferences.edit().clear().apply()
|
||||||
updateConfiguredState()
|
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) {
|
sealed class SyncException(message: String) : Exception(message) {
|
||||||
|
|||||||
@@ -56,7 +56,7 @@ fun ImagePicker(
|
|||||||
// Process images
|
// Process images
|
||||||
val newImagePaths = mutableListOf<String>()
|
val newImagePaths = mutableListOf<String>()
|
||||||
urisToProcess.forEach { uri ->
|
urisToProcess.forEach { uri ->
|
||||||
val imagePath = ImageUtils.saveImageFromUri(context, uri)
|
val imagePath = ImageUtils.saveTemporaryImageFromUri(context, uri)
|
||||||
if (imagePath != null) {
|
if (imagePath != null) {
|
||||||
newImagePaths.add(imagePath)
|
newImagePaths.add(imagePath)
|
||||||
}
|
}
|
||||||
@@ -76,7 +76,7 @@ fun ImagePicker(
|
|||||||
success ->
|
success ->
|
||||||
if (success) {
|
if (success) {
|
||||||
cameraImageUri?.let { uri ->
|
cameraImageUri?.let { uri ->
|
||||||
val imagePath = ImageUtils.saveImageFromUri(context, uri)
|
val imagePath = ImageUtils.saveTemporaryImageFromUri(context, uri)
|
||||||
if (imagePath != null) {
|
if (imagePath != null) {
|
||||||
val updatedUris = tempImageUris + imagePath
|
val updatedUris = tempImageUris + imagePath
|
||||||
tempImageUris = updatedUris
|
tempImageUris = updatedUris
|
||||||
|
|||||||
@@ -248,6 +248,7 @@ fun AddEditProblemScreen(
|
|||||||
) {
|
) {
|
||||||
val isEditing = problemId != null
|
val isEditing = problemId != null
|
||||||
val gyms by viewModel.gyms.collectAsState()
|
val gyms by viewModel.gyms.collectAsState()
|
||||||
|
val context = LocalContext.current
|
||||||
|
|
||||||
// Problem form state
|
// Problem form state
|
||||||
var selectedGym by remember {
|
var selectedGym by remember {
|
||||||
@@ -387,10 +388,11 @@ fun AddEditProblemScreen(
|
|||||||
|
|
||||||
if (isEditing) {
|
if (isEditing) {
|
||||||
viewModel.updateProblem(
|
viewModel.updateProblem(
|
||||||
problem.copy(id = problemId!!)
|
problem.copy(id = problemId),
|
||||||
|
context
|
||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
viewModel.addProblem(problem)
|
viewModel.addProblem(problem, context)
|
||||||
}
|
}
|
||||||
onNavigateBack()
|
onNavigateBack()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -537,7 +537,7 @@ fun SessionDetailScreen(
|
|||||||
viewModel.addAttempt(attempt)
|
viewModel.addAttempt(attempt)
|
||||||
showAddAttemptDialog = false
|
showAddAttemptDialog = false
|
||||||
},
|
},
|
||||||
onProblemCreated = { problem -> viewModel.addProblem(problem) }
|
onProblemCreated = { problem -> viewModel.addProblem(problem, context) }
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import androidx.compose.material3.*
|
|||||||
import androidx.compose.runtime.*
|
import androidx.compose.runtime.*
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.platform.LocalContext
|
||||||
import androidx.compose.ui.res.painterResource
|
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
|
||||||
@@ -25,6 +26,7 @@ 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 context = LocalContext.current
|
||||||
var showImageViewer by remember { mutableStateOf(false) }
|
var showImageViewer by remember { mutableStateOf(false) }
|
||||||
var selectedImagePaths by remember { mutableStateOf<List<String>>(emptyList()) }
|
var selectedImagePaths by remember { mutableStateOf<List<String>>(emptyList()) }
|
||||||
var selectedImageIndex by remember { mutableIntStateOf(0) }
|
var selectedImageIndex by remember { mutableIntStateOf(0) }
|
||||||
@@ -184,7 +186,7 @@ fun ProblemsScreen(viewModel: ClimbViewModel, onNavigateToProblemDetail: (String
|
|||||||
},
|
},
|
||||||
onToggleActive = {
|
onToggleActive = {
|
||||||
val updatedProblem = problem.copy(isActive = !problem.isActive)
|
val updatedProblem = problem.copy(isActive = !problem.isActive)
|
||||||
viewModel.updateProblem(updatedProblem)
|
viewModel.updateProblem(updatedProblem, context)
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
Spacer(modifier = Modifier.height(8.dp))
|
Spacer(modifier = Modifier.height(8.dp))
|
||||||
|
|||||||
@@ -42,6 +42,10 @@ 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 isFixingImages by remember { mutableStateOf(false) }
|
||||||
|
var isDeletingImages by remember { mutableStateOf(false) }
|
||||||
|
|
||||||
// Sync configuration state
|
// Sync configuration state
|
||||||
var serverUrl by remember { mutableStateOf(syncService.serverURL) }
|
var serverUrl by remember { mutableStateOf(syncService.serverURL) }
|
||||||
@@ -475,6 +479,88 @@ 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(
|
||||||
|
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(
|
Card(
|
||||||
shape = RoundedCornerShape(12.dp),
|
shape = RoundedCornerShape(12.dp),
|
||||||
colors =
|
colors =
|
||||||
@@ -903,16 +989,72 @@ fun SettingsScreen(viewModel: ClimbViewModel) {
|
|||||||
confirmButton = {
|
confirmButton = {
|
||||||
TextButton(
|
TextButton(
|
||||||
onClick = {
|
onClick = {
|
||||||
syncService.clearConfiguration()
|
viewModel.syncService.clearConfiguration()
|
||||||
serverUrl = ""
|
|
||||||
authToken = ""
|
|
||||||
showDisconnectDialog = false
|
showDisconnectDialog = false
|
||||||
}
|
}
|
||||||
) { Text("Disconnect", color = MaterialTheme.colorScheme.error) }
|
) { Text("Disconnect") }
|
||||||
},
|
},
|
||||||
dismissButton = {
|
dismissButton = {
|
||||||
TextButton(onClick = { showDisconnectDialog = false }) { Text("Cancel") }
|
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") }
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import com.atridad.openclimb.data.model.*
|
|||||||
import com.atridad.openclimb.data.repository.ClimbRepository
|
import com.atridad.openclimb.data.repository.ClimbRepository
|
||||||
import com.atridad.openclimb.data.sync.SyncService
|
import com.atridad.openclimb.data.sync.SyncService
|
||||||
import com.atridad.openclimb.service.SessionTrackingService
|
import com.atridad.openclimb.service.SessionTrackingService
|
||||||
|
import com.atridad.openclimb.utils.ImageNamingUtils
|
||||||
import com.atridad.openclimb.utils.ImageUtils
|
import com.atridad.openclimb.utils.ImageUtils
|
||||||
import com.atridad.openclimb.utils.SessionShareUtils
|
import com.atridad.openclimb.utils.SessionShareUtils
|
||||||
import com.atridad.openclimb.widget.ClimbStatsWidgetProvider
|
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)) }
|
fun getGymById(id: String): Flow<Gym?> = flow { emit(repository.getGymById(id)) }
|
||||||
|
|
||||||
// Problem operations
|
// Problem operations
|
||||||
fun addProblem(problem: Problem) {
|
|
||||||
viewModelScope.launch { repository.insertProblem(problem) }
|
|
||||||
}
|
|
||||||
|
|
||||||
fun addProblem(problem: Problem, context: Context) {
|
fun addProblem(problem: Problem, context: Context) {
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
repository.insertProblem(problem)
|
val finalProblem = renameTemporaryImages(problem, context)
|
||||||
|
repository.insertProblem(finalProblem)
|
||||||
ClimbStatsWidgetProvider.updateAllWidgets(context)
|
ClimbStatsWidgetProvider.updateAllWidgets(context)
|
||||||
// Auto-sync now happens automatically via repository callback
|
// Auto-sync now happens automatically via repository callback
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun updateProblem(problem: Problem) {
|
private suspend fun renameTemporaryImages(problem: Problem, context: Context? = null): Problem {
|
||||||
viewModelScope.launch { repository.updateProblem(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) {
|
fun updateProblem(problem: Problem, context: Context) {
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
repository.updateProblem(problem)
|
val finalProblem = renameTemporaryImages(problem, context)
|
||||||
|
repository.updateProblem(finalProblem)
|
||||||
ClimbStatsWidgetProvider.updateAllWidgets(context)
|
ClimbStatsWidgetProvider.updateAllWidgets(context)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -147,6 +164,99 @@ class ClimbViewModel(private val repository: ClimbRepository, val syncService: S
|
|||||||
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) {
|
||||||
|
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)) }
|
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)
|
ClimbStatsWidgetProvider.updateAllWidgets(context)
|
||||||
|
|
||||||
android.util.Log.d("ClimbViewModel", "Session started successfully")
|
|
||||||
_uiState.value = _uiState.value.copy(message = "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)
|
ClimbStatsWidgetProvider.updateAllWidgets(context)
|
||||||
|
|
||||||
// Auto-sync now happens automatically via repository callback
|
|
||||||
|
|
||||||
_uiState.value = _uiState.value.copy(message = "Session completed!")
|
_uiState.value = _uiState.value.copy(message = "Session completed!")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -295,7 +402,6 @@ class ClimbViewModel(private val repository: ClimbRepository, val syncService: S
|
|||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
repository.insertAttempt(attempt)
|
repository.insertAttempt(attempt)
|
||||||
ClimbStatsWidgetProvider.updateAllWidgets(context)
|
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)
|
_uiState.value = _uiState.value.copy(error = message)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun setMessage(message: String) {
|
||||||
|
_uiState.value = _uiState.value.copy(message = message)
|
||||||
|
}
|
||||||
|
|
||||||
fun resetAllData() {
|
fun resetAllData() {
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -13,18 +13,16 @@ object ImageNamingUtils {
|
|||||||
private const val HASH_LENGTH = 12 // First 12 chars of SHA-256
|
private const val HASH_LENGTH = 12 // First 12 chars of SHA-256
|
||||||
|
|
||||||
/** Generates a deterministic filename for a problem image */
|
/** Generates a deterministic filename for a problem image */
|
||||||
fun generateImageFilename(problemId: String, timestamp: String, imageIndex: Int): String {
|
fun generateImageFilename(problemId: String, imageIndex: Int): String {
|
||||||
// Create a deterministic hash from problemId + timestamp + index
|
val input = "${problemId}_${imageIndex}"
|
||||||
val input = "${problemId}_${timestamp}_${imageIndex}"
|
|
||||||
val hash = createHash(input)
|
val hash = createHash(input)
|
||||||
|
|
||||||
return "problem_${hash}_${imageIndex}${IMAGE_EXTENSION}"
|
return "problem_${hash}_${imageIndex}${IMAGE_EXTENSION}"
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Generates a deterministic filename using current timestamp */
|
/** Legacy method for backward compatibility */
|
||||||
fun generateImageFilename(problemId: String, imageIndex: Int): String {
|
fun generateImageFilename(problemId: String, timestamp: String, imageIndex: Int): String {
|
||||||
val timestamp = DateFormatUtils.nowISO8601()
|
return generateImageFilename(problemId, imageIndex)
|
||||||
return generateImageFilename(problemId, timestamp, imageIndex)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Extracts problem ID from an image filename */
|
/** Extracts problem ID from an image filename */
|
||||||
@@ -41,9 +39,7 @@ object ImageNamingUtils {
|
|||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
// We can't extract the original problem ID from the hash,
|
return parts[1]
|
||||||
// but we can validate the format
|
|
||||||
return parts[1] // Return the hash as identifier
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Validates if a filename follows our naming convention */
|
/** Validates if a filename follows our naming convention */
|
||||||
@@ -63,15 +59,11 @@ object ImageNamingUtils {
|
|||||||
|
|
||||||
/** Migrates an existing filename to our naming convention */
|
/** Migrates an existing filename to our naming convention */
|
||||||
fun migrateFilename(oldFilename: String, problemId: String, imageIndex: Int): String {
|
fun migrateFilename(oldFilename: String, problemId: String, imageIndex: Int): String {
|
||||||
// If it's already using our convention, keep it
|
|
||||||
if (isValidImageFilename(oldFilename)) {
|
if (isValidImageFilename(oldFilename)) {
|
||||||
return oldFilename
|
return oldFilename
|
||||||
}
|
}
|
||||||
|
|
||||||
// Generate new deterministic name
|
return generateImageFilename(problemId, imageIndex)
|
||||||
// Use a timestamp based on the old filename to maintain some consistency
|
|
||||||
val timestamp = DateFormatUtils.nowISO8601()
|
|
||||||
return generateImageFilename(problemId, timestamp, imageIndex)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Creates a deterministic hash from input string */
|
/** Creates a deterministic hash from input string */
|
||||||
@@ -90,7 +82,7 @@ object ImageNamingUtils {
|
|||||||
val renameMap = mutableMapOf<String, String>()
|
val renameMap = mutableMapOf<String, String>()
|
||||||
|
|
||||||
existingFilenames.forEachIndexed { index, oldFilename ->
|
existingFilenames.forEachIndexed { index, oldFilename ->
|
||||||
val newFilename = migrateFilename(oldFilename, problemId, index)
|
val newFilename = generateImageFilename(problemId, index)
|
||||||
if (newFilename != oldFilename) {
|
if (newFilename != oldFilename) {
|
||||||
renameMap[oldFilename] = newFilename
|
renameMap[oldFilename] = newFilename
|
||||||
}
|
}
|
||||||
@@ -98,4 +90,37 @@ object ImageNamingUtils {
|
|||||||
|
|
||||||
return renameMap
|
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
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,6 +5,8 @@ import android.content.Context
|
|||||||
import android.graphics.Bitmap
|
import android.graphics.Bitmap
|
||||||
import android.graphics.BitmapFactory
|
import android.graphics.BitmapFactory
|
||||||
import android.net.Uri
|
import android.net.Uri
|
||||||
|
import android.provider.MediaStore
|
||||||
|
import android.util.Log
|
||||||
import androidx.core.graphics.scale
|
import androidx.core.graphics.scale
|
||||||
import java.io.File
|
import java.io.File
|
||||||
import java.io.FileOutputStream
|
import java.io.FileOutputStream
|
||||||
@@ -17,7 +19,7 @@ object ImageUtils {
|
|||||||
private const val IMAGE_QUALITY = 85
|
private const val IMAGE_QUALITY = 85
|
||||||
|
|
||||||
// Creates the images directory if it doesn't exist
|
// Creates the images directory if it doesn't exist
|
||||||
private fun getImagesDirectory(context: Context): File {
|
fun getImagesDirectory(context: Context): File {
|
||||||
val imagesDir = File(context.filesDir, IMAGES_DIR)
|
val imagesDir = File(context.filesDir, IMAGES_DIR)
|
||||||
if (!imagesDir.exists()) {
|
if (!imagesDir.exists()) {
|
||||||
imagesDir.mkdirs()
|
imagesDir.mkdirs()
|
||||||
@@ -43,12 +45,12 @@ object ImageUtils {
|
|||||||
val orientedBitmap = correctImageOrientation(context, imageUri, originalBitmap)
|
val orientedBitmap = correctImageOrientation(context, imageUri, originalBitmap)
|
||||||
val compressedBitmap = compressImage(orientedBitmap)
|
val compressedBitmap = compressImage(orientedBitmap)
|
||||||
|
|
||||||
val filename =
|
// Always require deterministic naming - no UUID fallback
|
||||||
if (problemId != null && imageIndex != null) {
|
require(problemId != null && imageIndex != null) {
|
||||||
ImageNamingUtils.generateImageFilename(problemId, imageIndex)
|
"Problem ID and image index are required for deterministic image naming"
|
||||||
} else {
|
|
||||||
"${UUID.randomUUID()}.jpg"
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
val filename = ImageNamingUtils.generateImageFilename(problemId, imageIndex)
|
||||||
val imageFile = File(getImagesDirectory(context), filename)
|
val imageFile = File(getImagesDirectory(context), filename)
|
||||||
|
|
||||||
FileOutputStream(imageFile).use { output ->
|
FileOutputStream(imageFile).use { output ->
|
||||||
@@ -73,35 +75,35 @@ object ImageUtils {
|
|||||||
return try {
|
return try {
|
||||||
val inputStream = context.contentResolver.openInputStream(imageUri)
|
val inputStream = context.contentResolver.openInputStream(imageUri)
|
||||||
inputStream?.use { input ->
|
inputStream?.use { input ->
|
||||||
val exif = android.media.ExifInterface(input)
|
val exif = androidx.exifinterface.media.ExifInterface(input)
|
||||||
val orientation =
|
val orientation =
|
||||||
exif.getAttributeInt(
|
exif.getAttributeInt(
|
||||||
android.media.ExifInterface.TAG_ORIENTATION,
|
androidx.exifinterface.media.ExifInterface.TAG_ORIENTATION,
|
||||||
android.media.ExifInterface.ORIENTATION_NORMAL
|
androidx.exifinterface.media.ExifInterface.ORIENTATION_NORMAL
|
||||||
)
|
)
|
||||||
|
|
||||||
val matrix = android.graphics.Matrix()
|
val matrix = android.graphics.Matrix()
|
||||||
when (orientation) {
|
when (orientation) {
|
||||||
android.media.ExifInterface.ORIENTATION_ROTATE_90 -> {
|
androidx.exifinterface.media.ExifInterface.ORIENTATION_ROTATE_90 -> {
|
||||||
matrix.postRotate(90f)
|
matrix.postRotate(90f)
|
||||||
}
|
}
|
||||||
android.media.ExifInterface.ORIENTATION_ROTATE_180 -> {
|
androidx.exifinterface.media.ExifInterface.ORIENTATION_ROTATE_180 -> {
|
||||||
matrix.postRotate(180f)
|
matrix.postRotate(180f)
|
||||||
}
|
}
|
||||||
android.media.ExifInterface.ORIENTATION_ROTATE_270 -> {
|
androidx.exifinterface.media.ExifInterface.ORIENTATION_ROTATE_270 -> {
|
||||||
matrix.postRotate(270f)
|
matrix.postRotate(270f)
|
||||||
}
|
}
|
||||||
android.media.ExifInterface.ORIENTATION_FLIP_HORIZONTAL -> {
|
androidx.exifinterface.media.ExifInterface.ORIENTATION_FLIP_HORIZONTAL -> {
|
||||||
matrix.postScale(-1f, 1f)
|
matrix.postScale(-1f, 1f)
|
||||||
}
|
}
|
||||||
android.media.ExifInterface.ORIENTATION_FLIP_VERTICAL -> {
|
androidx.exifinterface.media.ExifInterface.ORIENTATION_FLIP_VERTICAL -> {
|
||||||
matrix.postScale(1f, -1f)
|
matrix.postScale(1f, -1f)
|
||||||
}
|
}
|
||||||
android.media.ExifInterface.ORIENTATION_TRANSPOSE -> {
|
androidx.exifinterface.media.ExifInterface.ORIENTATION_TRANSPOSE -> {
|
||||||
matrix.postRotate(90f)
|
matrix.postRotate(90f)
|
||||||
matrix.postScale(-1f, 1f)
|
matrix.postScale(-1f, 1f)
|
||||||
}
|
}
|
||||||
android.media.ExifInterface.ORIENTATION_TRANSVERSE -> {
|
androidx.exifinterface.media.ExifInterface.ORIENTATION_TRANSVERSE -> {
|
||||||
matrix.postRotate(-90f)
|
matrix.postRotate(-90f)
|
||||||
matrix.postScale(-1f, 1f)
|
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 */
|
/** Saves an image from byte array to app's private storage */
|
||||||
fun saveImageFromBytes(context: Context, imageData: ByteArray): String? {
|
fun saveImageFromBytes(context: Context, imageData: ByteArray): String? {
|
||||||
return try {
|
return try {
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ kotlinxSerialization = "1.9.0"
|
|||||||
kotlinxCoroutines = "1.10.2"
|
kotlinxCoroutines = "1.10.2"
|
||||||
coil = "2.7.0"
|
coil = "2.7.0"
|
||||||
ksp = "2.2.20-2.0.3"
|
ksp = "2.2.20-2.0.3"
|
||||||
|
exifinterface = "1.3.6"
|
||||||
|
|
||||||
[libraries]
|
[libraries]
|
||||||
androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" }
|
androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" }
|
||||||
@@ -66,6 +67,7 @@ mockk = { group = "io.mockk", name = "mockk", version = "1.14.6" }
|
|||||||
|
|
||||||
# Image Loading
|
# Image Loading
|
||||||
coil-compose = { group = "io.coil-kt", name = "coil-compose", version.ref = "coil" }
|
coil-compose = { group = "io.coil-kt", name = "coil-compose", version.ref = "coil" }
|
||||||
|
androidx-exifinterface = { group = "androidx.exifinterface", name = "exifinterface", version.ref = "exifinterface" }
|
||||||
|
|
||||||
[plugins]
|
[plugins]
|
||||||
android-application = { id = "com.android.application", version.ref = "agp" }
|
android-application = { id = "com.android.application", version.ref = "agp" }
|
||||||
|
|||||||
@@ -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 = 22;
|
CURRENT_PROJECT_VERSION = 23;
|
||||||
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 = 22;
|
CURRENT_PROJECT_VERSION = 23;
|
||||||
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 = 22;
|
CURRENT_PROJECT_VERSION = 23;
|
||||||
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 = 22;
|
CURRENT_PROJECT_VERSION = 23;
|
||||||
DEVELOPMENT_TEAM = 4BC9Y2LL4B;
|
DEVELOPMENT_TEAM = 4BC9Y2LL4B;
|
||||||
GENERATE_INFOPLIST_FILE = YES;
|
GENERATE_INFOPLIST_FILE = YES;
|
||||||
INFOPLIST_FILE = SessionStatusLive/Info.plist;
|
INFOPLIST_FILE = SessionStatusLive/Info.plist;
|
||||||
|
|||||||
Binary file not shown.
@@ -44,8 +44,10 @@ struct PhotoOptionSheet: View {
|
|||||||
.buttonStyle(PlainButtonStyle())
|
.buttonStyle(PlainButtonStyle())
|
||||||
|
|
||||||
Button(action: {
|
Button(action: {
|
||||||
onCameraSelected()
|
|
||||||
onDismiss()
|
onDismiss()
|
||||||
|
DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) {
|
||||||
|
onCameraSelected()
|
||||||
|
}
|
||||||
}) {
|
}) {
|
||||||
HStack {
|
HStack {
|
||||||
Image(systemName: "camera.fill")
|
Image(systemName: "camera.fill")
|
||||||
|
|||||||
@@ -11,6 +11,9 @@ class SyncService: ObservableObject {
|
|||||||
@Published var isTesting = false
|
@Published var isTesting = false
|
||||||
|
|
||||||
private let userDefaults = UserDefaults.standard
|
private let userDefaults = UserDefaults.standard
|
||||||
|
private var syncTask: Task<Void, Never>?
|
||||||
|
private var pendingChanges = false
|
||||||
|
private let syncDebounceDelay: TimeInterval = 2.0
|
||||||
|
|
||||||
private enum Keys {
|
private enum Keys {
|
||||||
static let serverURL = "sync_server_url"
|
static let serverURL = "sync_server_url"
|
||||||
@@ -44,6 +47,11 @@ class SyncService: ObservableObject {
|
|||||||
self.lastSyncTime = lastSync
|
self.lastSyncTime = lastSync
|
||||||
}
|
}
|
||||||
self.isConnected = userDefaults.bool(forKey: Keys.isConnected)
|
self.isConnected = userDefaults.bool(forKey: Keys.isConnected)
|
||||||
|
|
||||||
|
// Perform image naming migration on initialization
|
||||||
|
Task {
|
||||||
|
await performImageNamingMigration()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func downloadData() async throws -> ClimbDataBackup {
|
func downloadData() async throws -> ClimbDataBackup {
|
||||||
@@ -144,6 +152,9 @@ class SyncService: ObservableObject {
|
|||||||
request.setValue("application/octet-stream", forHTTPHeaderField: "Content-Type")
|
request.setValue("application/octet-stream", forHTTPHeaderField: "Content-Type")
|
||||||
request.httpBody = imageData
|
request.httpBody = imageData
|
||||||
|
|
||||||
|
request.timeoutInterval = 60.0
|
||||||
|
request.cachePolicy = .reloadIgnoringLocalCacheData
|
||||||
|
|
||||||
let (_, response) = try await URLSession.shared.data(for: request)
|
let (_, response) = try await URLSession.shared.data(for: request)
|
||||||
|
|
||||||
guard let httpResponse = response as? HTTPURLResponse else {
|
guard let httpResponse = response as? HTTPURLResponse else {
|
||||||
@@ -173,6 +184,9 @@ class SyncService: ObservableObject {
|
|||||||
request.httpMethod = "GET"
|
request.httpMethod = "GET"
|
||||||
request.setValue("Bearer \(authToken)", forHTTPHeaderField: "Authorization")
|
request.setValue("Bearer \(authToken)", forHTTPHeaderField: "Authorization")
|
||||||
|
|
||||||
|
request.timeoutInterval = 45.0
|
||||||
|
request.cachePolicy = .returnCacheDataElseLoad
|
||||||
|
|
||||||
let (data, response) = try await URLSession.shared.data(for: request)
|
let (data, response) = try await URLSession.shared.data(for: request)
|
||||||
|
|
||||||
guard let httpResponse = response as? HTTPURLResponse else {
|
guard let httpResponse = response as? HTTPURLResponse else {
|
||||||
@@ -283,7 +297,6 @@ class SyncService: ObservableObject {
|
|||||||
{
|
{
|
||||||
var imagePathMapping: [String: String] = [:]
|
var imagePathMapping: [String: String] = [:]
|
||||||
|
|
||||||
// Process images by problem to maintain consistent naming
|
|
||||||
for problem in backup.problems {
|
for problem in backup.problems {
|
||||||
guard let imagePaths = problem.imagePaths, !imagePaths.isEmpty else { continue }
|
guard let imagePaths = problem.imagePaths, !imagePaths.isEmpty else { continue }
|
||||||
|
|
||||||
@@ -293,19 +306,13 @@ class SyncService: ObservableObject {
|
|||||||
do {
|
do {
|
||||||
let imageData = try await downloadImage(filename: serverFilename)
|
let imageData = try await downloadImage(filename: serverFilename)
|
||||||
|
|
||||||
// Generate consistent filename if needed
|
let consistentFilename = ImageNamingUtils.generateImageFilename(
|
||||||
let consistentFilename =
|
|
||||||
ImageNamingUtils.isValidImageFilename(serverFilename)
|
|
||||||
? serverFilename
|
|
||||||
: ImageNamingUtils.generateImageFilename(
|
|
||||||
problemId: problem.id, imageIndex: index)
|
problemId: problem.id, imageIndex: index)
|
||||||
|
|
||||||
// Save image with consistent filename
|
|
||||||
let imageManager = ImageManager.shared
|
let imageManager = ImageManager.shared
|
||||||
_ = try imageManager.saveImportedImage(
|
_ = try imageManager.saveImportedImage(
|
||||||
imageData, filename: consistentFilename)
|
imageData, filename: consistentFilename)
|
||||||
|
|
||||||
// Map server filename to consistent local filename
|
|
||||||
imagePathMapping[serverFilename] = consistentFilename
|
imagePathMapping[serverFilename] = consistentFilename
|
||||||
print("Downloaded and mapped image: \(serverFilename) -> \(consistentFilename)")
|
print("Downloaded and mapped image: \(serverFilename) -> \(consistentFilename)")
|
||||||
} catch SyncError.imageNotFound {
|
} catch SyncError.imageNotFound {
|
||||||
@@ -329,11 +336,7 @@ class SyncService: ObservableObject {
|
|||||||
for (index, imagePath) in problem.imagePaths.enumerated() {
|
for (index, imagePath) in problem.imagePaths.enumerated() {
|
||||||
let filename = URL(fileURLWithPath: imagePath).lastPathComponent
|
let filename = URL(fileURLWithPath: imagePath).lastPathComponent
|
||||||
|
|
||||||
// Ensure filename follows consistent naming convention
|
let consistentFilename = ImageNamingUtils.generateImageFilename(
|
||||||
let consistentFilename =
|
|
||||||
ImageNamingUtils.isValidImageFilename(filename)
|
|
||||||
? filename
|
|
||||||
: ImageNamingUtils.generateImageFilename(
|
|
||||||
problemId: problem.id.uuidString, imageIndex: index)
|
problemId: problem.id.uuidString, imageIndex: index)
|
||||||
|
|
||||||
// Load image data
|
// Load image data
|
||||||
@@ -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(
|
private func mergeDataSafely(
|
||||||
localBackup: ClimbDataBackup,
|
localBackup: ClimbDataBackup,
|
||||||
serverBackup: ClimbDataBackup,
|
serverBackup: ClimbDataBackup,
|
||||||
@@ -620,17 +670,31 @@ class SyncService: ObservableObject {
|
|||||||
}
|
}
|
||||||
let jsonData = try encoder.encode(backup)
|
let jsonData = try encoder.encode(backup)
|
||||||
|
|
||||||
// Collect all downloaded images from ImageManager
|
// Collect all images from ImageManager
|
||||||
let imageManager = ImageManager.shared
|
let imageManager = ImageManager.shared
|
||||||
var imageFiles: [(filename: String, data: Data)] = []
|
var imageFiles: [(filename: String, data: Data)] = []
|
||||||
let imagePaths = Set(backup.problems.flatMap { $0.imagePaths ?? [] })
|
|
||||||
|
|
||||||
for imagePath in imagePaths {
|
// Get original problems to access actual image paths on disk
|
||||||
let filename = URL(fileURLWithPath: imagePath).lastPathComponent
|
if let problemsData = userDefaults.data(forKey: "problems"),
|
||||||
let fullPath = imageManager.imagesDirectory.appendingPathComponent(filename).path
|
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) {
|
if let imageData = imageManager.loadImageData(fromPath: fullPath) {
|
||||||
imageFiles.append((filename: filename, data: imageData))
|
imageFiles.append((filename: normalizedFilename, data: imageData))
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -875,20 +939,51 @@ class SyncService: ObservableObject {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func triggerAutoSync(dataManager: ClimbingDataManager) {
|
func triggerAutoSync(dataManager: ClimbingDataManager) {
|
||||||
// Early exit if sync cannot proceed - don't set isSyncing
|
|
||||||
guard isConnected && isConfigured && isAutoSyncEnabled else {
|
guard isConnected && isConfigured && isAutoSyncEnabled else {
|
||||||
// Ensure isSyncing is false when sync is not possible
|
|
||||||
if isSyncing {
|
if isSyncing {
|
||||||
isSyncing = false
|
isSyncing = false
|
||||||
}
|
}
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Prevent multiple simultaneous syncs
|
if isSyncing {
|
||||||
guard !isSyncing else {
|
pendingChanges = true
|
||||||
return
|
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 {
|
Task {
|
||||||
do {
|
do {
|
||||||
try await syncWithServer(dataManager: dataManager)
|
try await syncWithServer(dataManager: dataManager)
|
||||||
@@ -901,6 +996,10 @@ class SyncService: ObservableObject {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func disconnect() {
|
func disconnect() {
|
||||||
|
syncTask?.cancel()
|
||||||
|
syncTask = nil
|
||||||
|
pendingChanges = false
|
||||||
|
isSyncing = false
|
||||||
isConnected = false
|
isConnected = false
|
||||||
lastSyncTime = nil
|
lastSyncTime = nil
|
||||||
syncError = nil
|
syncError = nil
|
||||||
@@ -917,6 +1016,112 @@ class SyncService: ObservableObject {
|
|||||||
userDefaults.removeObject(forKey: Keys.lastSyncTime)
|
userDefaults.removeObject(forKey: Keys.lastSyncTime)
|
||||||
userDefaults.removeObject(forKey: Keys.isConnected)
|
userDefaults.removeObject(forKey: Keys.isConnected)
|
||||||
userDefaults.removeObject(forKey: Keys.autoSyncEnabled)
|
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
|
// MARK: - Safe Merge Functions
|
||||||
@@ -926,13 +1131,14 @@ class SyncService: ObservableObject {
|
|||||||
var merged = local
|
var merged = local
|
||||||
let deletedGymIds = Set(deletedItems.filter { $0.type == "gym" }.map { $0.id })
|
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) }
|
merged.removeAll { deletedGymIds.contains($0.id.uuidString) }
|
||||||
|
|
||||||
// Add new items from server (excluding deleted ones)
|
// Add new items from server (excluding deleted ones)
|
||||||
for serverGym in server {
|
for serverGym in server {
|
||||||
if let serverGymConverted = try? serverGym.toGym() {
|
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)
|
let isDeleted = deletedGymIds.contains(serverGym.id)
|
||||||
|
|
||||||
if !localHasGym && !isDeleted {
|
if !localHasGym && !isDeleted {
|
||||||
@@ -953,18 +1159,24 @@ class SyncService: ObservableObject {
|
|||||||
var merged = local
|
var merged = local
|
||||||
let deletedProblemIds = Set(deletedItems.filter { $0.type == "problem" }.map { $0.id })
|
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) }
|
merged.removeAll { deletedProblemIds.contains($0.id.uuidString) }
|
||||||
|
|
||||||
// Add new items from server (excluding deleted ones)
|
|
||||||
for serverProblem in server {
|
for serverProblem in server {
|
||||||
|
let localHasProblem = localProblemIds.contains(serverProblem.id)
|
||||||
|
let isDeleted = deletedProblemIds.contains(serverProblem.id)
|
||||||
|
|
||||||
|
if !localHasProblem && !isDeleted {
|
||||||
var problemToAdd = serverProblem
|
var problemToAdd = serverProblem
|
||||||
|
|
||||||
// Update image paths if needed
|
if !imagePathMapping.isEmpty, let imagePaths = serverProblem.imagePaths,
|
||||||
if !imagePathMapping.isEmpty {
|
!imagePaths.isEmpty
|
||||||
let updatedImagePaths = serverProblem.imagePaths?.compactMap { oldPath in
|
{
|
||||||
|
let updatedImagePaths = imagePaths.compactMap { oldPath in
|
||||||
imagePathMapping[oldPath] ?? oldPath
|
imagePathMapping[oldPath] ?? oldPath
|
||||||
}
|
}
|
||||||
|
if updatedImagePaths != imagePaths {
|
||||||
problemToAdd = BackupProblem(
|
problemToAdd = BackupProblem(
|
||||||
id: serverProblem.id,
|
id: serverProblem.id,
|
||||||
gymId: serverProblem.gymId,
|
gymId: serverProblem.gymId,
|
||||||
@@ -982,12 +1194,9 @@ class SyncService: ObservableObject {
|
|||||||
updatedAt: serverProblem.updatedAt
|
updatedAt: serverProblem.updatedAt
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if let serverProblemConverted = try? problemToAdd.toProblem() {
|
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 {
|
|
||||||
merged.append(serverProblemConverted)
|
merged.append(serverProblemConverted)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1004,19 +1213,18 @@ class SyncService: ObservableObject {
|
|||||||
var merged = local
|
var merged = local
|
||||||
let deletedSessionIds = Set(deletedItems.filter { $0.type == "session" }.map { $0.id })
|
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
|
merged.removeAll { session in
|
||||||
deletedSessionIds.contains(session.id.uuidString) && session.status != .active
|
deletedSessionIds.contains(session.id.uuidString) && session.status != .active
|
||||||
}
|
}
|
||||||
|
|
||||||
// Add new items from server (excluding deleted ones)
|
|
||||||
for serverSession in server {
|
for serverSession in server {
|
||||||
if let serverSessionConverted = try? serverSession.toClimbSession() {
|
let localHasSession = localSessionIds.contains(serverSession.id)
|
||||||
let localHasSession = local.contains(where: { $0.id.uuidString == serverSession.id }
|
|
||||||
)
|
|
||||||
let isDeleted = deletedSessionIds.contains(serverSession.id)
|
let isDeleted = deletedSessionIds.contains(serverSession.id)
|
||||||
|
|
||||||
if !localHasSession && !isDeleted {
|
if !localHasSession && !isDeleted {
|
||||||
|
if let serverSessionConverted = try? serverSession.toClimbSession() {
|
||||||
merged.append(serverSessionConverted)
|
merged.append(serverSessionConverted)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1031,6 +1239,8 @@ class SyncService: ObservableObject {
|
|||||||
var merged = local
|
var merged = local
|
||||||
let deletedAttemptIds = Set(deletedItems.filter { $0.type == "attempt" }.map { $0.id })
|
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
|
// Get active session IDs to protect their attempts
|
||||||
let activeSessionIds = Set(
|
let activeSessionIds = Set(
|
||||||
local.compactMap { attempt in
|
local.compactMap { attempt in
|
||||||
@@ -1048,14 +1258,12 @@ class SyncService: ObservableObject {
|
|||||||
&& !activeSessionIds.contains(attempt.sessionId)
|
&& !activeSessionIds.contains(attempt.sessionId)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Add new items from server (excluding deleted ones)
|
|
||||||
for serverAttempt in server {
|
for serverAttempt in server {
|
||||||
if let serverAttemptConverted = try? serverAttempt.toAttempt() {
|
let localHasAttempt = localAttemptIds.contains(serverAttempt.id)
|
||||||
let localHasAttempt = local.contains(where: { $0.id.uuidString == serverAttempt.id }
|
|
||||||
)
|
|
||||||
let isDeleted = deletedAttemptIds.contains(serverAttempt.id)
|
let isDeleted = deletedAttemptIds.contains(serverAttempt.id)
|
||||||
|
|
||||||
if !localHasAttempt && !isDeleted {
|
if !localHasAttempt && !isDeleted {
|
||||||
|
if let serverAttemptConverted = try? serverAttempt.toAttempt() {
|
||||||
merged.append(serverAttemptConverted)
|
merged.append(serverAttemptConverted)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -852,4 +852,73 @@ class ImageManager {
|
|||||||
print("ERROR: Failed to migrate from previous Application Support: \(error)")
|
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"
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,21 +11,18 @@ class ImageNamingUtils {
|
|||||||
private static let hashLength = 12
|
private static let hashLength = 12
|
||||||
|
|
||||||
/// Generates a deterministic filename for a problem image
|
/// Generates a deterministic filename for a problem image
|
||||||
static func generateImageFilename(problemId: String, timestamp: String, imageIndex: Int)
|
static func generateImageFilename(problemId: String, imageIndex: Int) -> String {
|
||||||
-> String
|
let input = "\(problemId)_\(imageIndex)"
|
||||||
{
|
|
||||||
|
|
||||||
let input = "\(problemId)_\(timestamp)_\(imageIndex)"
|
|
||||||
let hash = createHash(from: input)
|
let hash = createHash(from: input)
|
||||||
|
|
||||||
return "problem_\(hash)_\(imageIndex)\(imageExtension)"
|
return "problem_\(hash)_\(imageIndex)\(imageExtension)"
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Generates a deterministic filename using current timestamp
|
/// Legacy method for backward compatibility
|
||||||
static func generateImageFilename(problemId: String, imageIndex: Int) -> String {
|
static func generateImageFilename(problemId: String, timestamp: String, imageIndex: Int)
|
||||||
let timestamp = ISO8601DateFormatter().string(from: Date())
|
-> String
|
||||||
return generateImageFilename(
|
{
|
||||||
problemId: problemId, timestamp: timestamp, imageIndex: imageIndex)
|
return generateImageFilename(problemId: problemId, imageIndex: imageIndex)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Extracts problem ID from an image filename
|
/// Extracts problem ID from an image filename
|
||||||
@@ -64,9 +61,7 @@ class ImageNamingUtils {
|
|||||||
return oldFilename
|
return oldFilename
|
||||||
}
|
}
|
||||||
|
|
||||||
let timestamp = ISO8601DateFormatter().string(from: Date())
|
return generateImageFilename(problemId: problemId, imageIndex: imageIndex)
|
||||||
return generateImageFilename(
|
|
||||||
problemId: problemId, timestamp: timestamp, imageIndex: imageIndex)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Creates a deterministic hash from input string
|
/// Creates a deterministic hash from input string
|
||||||
@@ -84,8 +79,7 @@ class ImageNamingUtils {
|
|||||||
var renameMap: [String: String] = [:]
|
var renameMap: [String: String] = [:]
|
||||||
|
|
||||||
for (index, oldFilename) in existingFilenames.enumerated() {
|
for (index, oldFilename) in existingFilenames.enumerated() {
|
||||||
let newFilename = migrateFilename(
|
let newFilename = generateImageFilename(problemId: problemId, imageIndex: index)
|
||||||
oldFilename: oldFilename, problemId: problemId, imageIndex: index)
|
|
||||||
if newFilename != oldFilename {
|
if newFilename != oldFilename {
|
||||||
renameMap[oldFilename] = newFilename
|
renameMap[oldFilename] = newFilename
|
||||||
}
|
}
|
||||||
@@ -113,6 +107,40 @@ class ImageNamingUtils {
|
|||||||
invalidImages: invalidImages
|
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
|
// Result of image filename validation
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ class ClimbingDataManager: ObservableObject {
|
|||||||
private let encoder = JSONEncoder()
|
private let encoder = JSONEncoder()
|
||||||
private let decoder = JSONDecoder()
|
private let decoder = JSONDecoder()
|
||||||
nonisolated(unsafe) private var liveActivityObserver: NSObjectProtocol?
|
nonisolated(unsafe) private var liveActivityObserver: NSObjectProtocol?
|
||||||
|
nonisolated(unsafe) private var migrationObserver: NSObjectProtocol?
|
||||||
|
|
||||||
let syncService = SyncService()
|
let syncService = SyncService()
|
||||||
let healthKitService = HealthKitService.shared
|
let healthKitService = HealthKitService.shared
|
||||||
@@ -68,8 +69,8 @@ class ClimbingDataManager: ObservableObject {
|
|||||||
init() {
|
init() {
|
||||||
_ = ImageManager.shared
|
_ = ImageManager.shared
|
||||||
loadAllData()
|
loadAllData()
|
||||||
migrateImagePaths()
|
|
||||||
setupLiveActivityNotifications()
|
setupLiveActivityNotifications()
|
||||||
|
setupMigrationNotifications()
|
||||||
|
|
||||||
// Keep our published isSyncing in sync with syncService.isSyncing
|
// Keep our published isSyncing in sync with syncService.isSyncing
|
||||||
syncService.$isSyncing
|
syncService.$isSyncing
|
||||||
@@ -88,6 +89,9 @@ class ClimbingDataManager: ObservableObject {
|
|||||||
if let observer = liveActivityObserver {
|
if let observer = liveActivityObserver {
|
||||||
NotificationCenter.default.removeObserver(observer)
|
NotificationCenter.default.removeObserver(observer)
|
||||||
}
|
}
|
||||||
|
if let observer = migrationObserver {
|
||||||
|
NotificationCenter.default.removeObserver(observer)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func loadAllData() {
|
private func loadAllData() {
|
||||||
@@ -632,7 +636,7 @@ class ClimbingDataManager: ObservableObject {
|
|||||||
saveAttempts()
|
saveAttempts()
|
||||||
let removedCount = initialAttemptCount - attempts.count
|
let removedCount = initialAttemptCount - attempts.count
|
||||||
print(
|
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"
|
report += "Orphaned Sessions: \(orphanedSessions.count)\n"
|
||||||
|
|
||||||
if orphanedAttempts.isEmpty && orphanedProblems.isEmpty && orphanedSessions.isEmpty {
|
if orphanedAttempts.isEmpty && orphanedProblems.isEmpty && orphanedSessions.isEmpty {
|
||||||
report += "\n✅ No integrity issues found"
|
report += "\nNo integrity issues found"
|
||||||
} else {
|
} else {
|
||||||
report += "\n⚠️ Issues found - run cleanup to fix"
|
report += "\nIssues found - run cleanup to fix"
|
||||||
}
|
}
|
||||||
|
|
||||||
return report
|
return report
|
||||||
@@ -749,6 +753,7 @@ class ClimbingDataManager: ObservableObject {
|
|||||||
let dateFormatter = DateFormatter()
|
let dateFormatter = DateFormatter()
|
||||||
dateFormatter.dateFormat = "yyyy-MM-dd'T'HH:mm:ss.SSSSSS"
|
dateFormatter.dateFormat = "yyyy-MM-dd'T'HH:mm:ss.SSSSSS"
|
||||||
|
|
||||||
|
// Create export data with normalized image paths
|
||||||
let exportData = ClimbDataBackup(
|
let exportData = ClimbDataBackup(
|
||||||
exportedAt: dateFormatter.string(from: Date()),
|
exportedAt: dateFormatter.string(from: Date()),
|
||||||
version: "2.0",
|
version: "2.0",
|
||||||
@@ -759,7 +764,7 @@ class ClimbingDataManager: ObservableObject {
|
|||||||
attempts: attempts.map { BackupAttempt(from: $0) }
|
attempts: attempts.map { BackupAttempt(from: $0) }
|
||||||
)
|
)
|
||||||
|
|
||||||
// Collect referenced image paths
|
// Collect actual image paths from disk for the ZIP
|
||||||
let referencedImagePaths = collectReferencedImagePaths()
|
let referencedImagePaths = collectReferencedImagePaths()
|
||||||
print("Starting export with \(referencedImagePaths.count) images")
|
print("Starting export with \(referencedImagePaths.count) images")
|
||||||
|
|
||||||
@@ -878,17 +883,19 @@ extension ClimbingDataManager {
|
|||||||
"Problem '\(problem.name ?? "Unnamed")' has \(problem.imagePaths.count) images"
|
"Problem '\(problem.name ?? "Unnamed")' has \(problem.imagePaths.count) images"
|
||||||
)
|
)
|
||||||
for imagePath in problem.imagePaths {
|
for imagePath in problem.imagePaths {
|
||||||
print(" - Relative path: \(imagePath)")
|
print(" - Stored path: \(imagePath)")
|
||||||
let fullPath = ImageManager.shared.getFullPath(from: imagePath)
|
|
||||||
print(" - Full path: \(fullPath)")
|
// 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) {
|
if FileManager.default.fileExists(atPath: fullPath) {
|
||||||
print(" File exists")
|
print(" ✓ File exists")
|
||||||
imagePaths.insert(fullPath)
|
imagePaths.insert(fullPath)
|
||||||
} else {
|
} else {
|
||||||
print(" File does NOT exist")
|
print(" ✗ WARNING: File not found at \(fullPath)")
|
||||||
// Still add it to let ZipUtils handle the error logging
|
// Still add it to let ZipUtils handle the logging
|
||||||
imagePaths.insert(fullPath)
|
imagePaths.insert(fullPath)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -904,11 +911,53 @@ extension ClimbingDataManager {
|
|||||||
imagePathMapping: [String: String]
|
imagePathMapping: [String: String]
|
||||||
) -> [BackupProblem] {
|
) -> [BackupProblem] {
|
||||||
return problems.map { problem in
|
return problems.map { problem in
|
||||||
let updatedImagePaths = (problem.imagePaths ?? []).compactMap { oldPath in
|
guard let originalImagePaths = problem.imagePaths, !originalImagePaths.isEmpty else {
|
||||||
let fileName = URL(fileURLWithPath: oldPath).lastPathComponent
|
return problem
|
||||||
return imagePathMapping[fileName]
|
|
||||||
}
|
}
|
||||||
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
|
/// Handle Live Activity being dismissed by user
|
||||||
private func handleLiveActivityDismissed() async {
|
private func handleLiveActivityDismissed() async {
|
||||||
guard let activeSession = activeSession,
|
guard let activeSession = activeSession,
|
||||||
|
|||||||
@@ -458,24 +458,36 @@ struct AddAttemptView: View {
|
|||||||
let difficulty = DifficultyGrade(
|
let difficulty = DifficultyGrade(
|
||||||
system: selectedDifficultySystem, grade: newProblemGrade)
|
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(
|
let newProblem = Problem(
|
||||||
gymId: gym.id,
|
gymId: gym.id,
|
||||||
name: newProblemName.isEmpty ? nil : newProblemName,
|
name: newProblemName.isEmpty ? nil : newProblemName,
|
||||||
climbType: selectedClimbType,
|
climbType: selectedClimbType,
|
||||||
difficulty: difficulty,
|
difficulty: difficulty,
|
||||||
imagePaths: imagePaths
|
imagePaths: []
|
||||||
)
|
)
|
||||||
|
|
||||||
dataManager.addProblem(newProblem)
|
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(
|
let attempt = Attempt(
|
||||||
sessionId: session.id,
|
sessionId: session.id,
|
||||||
problemId: newProblem.id,
|
problemId: newProblem.id,
|
||||||
@@ -1218,24 +1230,36 @@ struct EditAttemptView: View {
|
|||||||
let difficulty = DifficultyGrade(
|
let difficulty = DifficultyGrade(
|
||||||
system: selectedDifficultySystem, grade: newProblemGrade)
|
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(
|
let newProblem = Problem(
|
||||||
gymId: gym.id,
|
gymId: gym.id,
|
||||||
name: newProblemName.isEmpty ? nil : newProblemName,
|
name: newProblemName.isEmpty ? nil : newProblemName,
|
||||||
climbType: selectedClimbType,
|
climbType: selectedClimbType,
|
||||||
difficulty: difficulty,
|
difficulty: difficulty,
|
||||||
imagePaths: imagePaths
|
imagePaths: []
|
||||||
)
|
)
|
||||||
|
|
||||||
dataManager.addProblem(newProblem)
|
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(
|
let updatedAttempt = attempt.updated(
|
||||||
problemId: newProblem.id,
|
problemId: newProblem.id,
|
||||||
result: selectedResult,
|
result: selectedResult,
|
||||||
@@ -1329,16 +1353,18 @@ struct ProblemSelectionImageView: View {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
DispatchQueue.global(qos: .userInitiated).async {
|
Task {
|
||||||
if let data = ImageManager.shared.loadImageData(fromPath: imagePath),
|
let data = await MainActor.run {
|
||||||
let image = UIImage(data: data)
|
ImageManager.shared.loadImageData(fromPath: imagePath)
|
||||||
{
|
}
|
||||||
DispatchQueue.main.async {
|
|
||||||
|
if let data = data, let image = UIImage(data: data) {
|
||||||
|
await MainActor.run {
|
||||||
self.uiImage = image
|
self.uiImage = image
|
||||||
self.isLoading = false
|
self.isLoading = false
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
DispatchQueue.main.async {
|
await MainActor.run {
|
||||||
self.hasFailed = true
|
self.hasFailed = true
|
||||||
self.isLoading = false
|
self.isLoading = false
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -556,21 +556,25 @@ struct AddEditProblemView: View {
|
|||||||
|
|
||||||
let difficulty = DifficultyGrade(system: selectedDifficultySystem, grade: difficultyGrade)
|
let difficulty = DifficultyGrade(system: selectedDifficultySystem, grade: difficultyGrade)
|
||||||
|
|
||||||
// Save new image data and combine with existing paths
|
if isEditing, let problem = existingProblem {
|
||||||
var allImagePaths = imagePaths
|
var allImagePaths = imagePaths
|
||||||
|
|
||||||
// Only save NEW images (those beyond the existing imagePaths count)
|
|
||||||
let newImagesStartIndex = imagePaths.count
|
let newImagesStartIndex = imagePaths.count
|
||||||
if imageData.count > newImagesStartIndex {
|
if imageData.count > newImagesStartIndex {
|
||||||
for i in newImagesStartIndex..<imageData.count {
|
for i in newImagesStartIndex..<imageData.count {
|
||||||
let data = imageData[i]
|
let data = imageData[i]
|
||||||
if let relativePath = ImageManager.shared.saveImageData(data) {
|
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)
|
allImagePaths.append(relativePath)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if isEditing, let problem = existingProblem {
|
|
||||||
let updatedProblem = problem.updated(
|
let updatedProblem = problem.updated(
|
||||||
name: trimmedName.isEmpty ? nil : trimmedName,
|
name: trimmedName.isEmpty ? nil : trimmedName,
|
||||||
description: trimmedDescription.isEmpty ? nil : trimmedDescription,
|
description: trimmedDescription.isEmpty ? nil : trimmedDescription,
|
||||||
@@ -595,11 +599,32 @@ struct AddEditProblemView: View {
|
|||||||
|
|
||||||
tags: trimmedTags,
|
tags: trimmedTags,
|
||||||
location: trimmedLocation.isEmpty ? nil : trimmedLocation,
|
location: trimmedLocation.isEmpty ? nil : trimmedLocation,
|
||||||
imagePaths: allImagePaths,
|
imagePaths: [],
|
||||||
dateSet: dateSet,
|
dateSet: dateSet,
|
||||||
notes: trimmedNotes.isEmpty ? nil : trimmedNotes
|
notes: trimmedNotes.isEmpty ? nil : trimmedNotes
|
||||||
)
|
)
|
||||||
|
|
||||||
dataManager.addProblem(newProblem)
|
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()
|
dismiss()
|
||||||
|
|||||||
@@ -486,16 +486,18 @@ struct ProblemDetailImageView: View {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
DispatchQueue.global(qos: .userInitiated).async {
|
Task {
|
||||||
if let data = ImageManager.shared.loadImageData(fromPath: imagePath),
|
let data = await MainActor.run {
|
||||||
let image = UIImage(data: data)
|
ImageManager.shared.loadImageData(fromPath: imagePath)
|
||||||
{
|
}
|
||||||
DispatchQueue.main.async {
|
|
||||||
|
if let data = data, let image = UIImage(data: data) {
|
||||||
|
await MainActor.run {
|
||||||
self.uiImage = image
|
self.uiImage = image
|
||||||
self.isLoading = false
|
self.isLoading = false
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
DispatchQueue.main.async {
|
await MainActor.run {
|
||||||
self.hasFailed = true
|
self.hasFailed = true
|
||||||
self.isLoading = false
|
self.isLoading = false
|
||||||
}
|
}
|
||||||
@@ -550,16 +552,18 @@ struct ProblemDetailImageFullView: View {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
DispatchQueue.global(qos: .userInitiated).async {
|
Task {
|
||||||
if let data = ImageManager.shared.loadImageData(fromPath: imagePath),
|
let data = await MainActor.run {
|
||||||
let image = UIImage(data: data)
|
ImageManager.shared.loadImageData(fromPath: imagePath)
|
||||||
{
|
}
|
||||||
DispatchQueue.main.async {
|
|
||||||
|
if let data = data, let image = UIImage(data: data) {
|
||||||
|
await MainActor.run {
|
||||||
self.uiImage = image
|
self.uiImage = image
|
||||||
self.isLoading = false
|
self.isLoading = false
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
DispatchQueue.main.async {
|
await MainActor.run {
|
||||||
self.hasFailed = true
|
self.hasFailed = true
|
||||||
self.isLoading = false
|
self.isLoading = false
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -484,7 +484,7 @@ struct ProblemImageView: View {
|
|||||||
@State private var isLoading = true
|
@State private var isLoading = true
|
||||||
@State private var hasFailed = false
|
@State private var hasFailed = false
|
||||||
|
|
||||||
private static var imageCache: NSCache<NSString, UIImage> = {
|
private static let imageCache: NSCache<NSString, UIImage> = {
|
||||||
let cache = NSCache<NSString, UIImage>()
|
let cache = NSCache<NSString, UIImage>()
|
||||||
cache.countLimit = 100
|
cache.countLimit = 100
|
||||||
cache.totalCostLimit = 50 * 1024 * 1024 // 50MB
|
cache.totalCostLimit = 50 * 1024 * 1024 // 50MB
|
||||||
@@ -531,6 +531,8 @@ struct ProblemImageView: View {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Load image asynchronously
|
||||||
|
Task { @MainActor in
|
||||||
let cacheKey = NSString(string: imagePath)
|
let cacheKey = NSString(string: imagePath)
|
||||||
|
|
||||||
// Check cache first
|
// Check cache first
|
||||||
@@ -540,25 +542,20 @@ struct ProblemImageView: View {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
DispatchQueue.global(qos: .userInitiated).async {
|
// Load image data
|
||||||
if let data = ImageManager.shared.loadImageData(fromPath: imagePath),
|
if let data = ImageManager.shared.loadImageData(fromPath: imagePath),
|
||||||
let image = UIImage(data: data)
|
let image = UIImage(data: data)
|
||||||
{
|
{
|
||||||
// Cache the image
|
// Cache the image
|
||||||
Self.imageCache.setObject(image, forKey: cacheKey)
|
Self.imageCache.setObject(image, forKey: cacheKey)
|
||||||
|
|
||||||
DispatchQueue.main.async {
|
|
||||||
self.uiImage = image
|
self.uiImage = image
|
||||||
self.isLoading = false
|
self.isLoading = false
|
||||||
}
|
|
||||||
} else {
|
} else {
|
||||||
DispatchQueue.main.async {
|
|
||||||
self.hasFailed = true
|
self.hasFailed = true
|
||||||
self.isLoading = false
|
self.isLoading = false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#Preview {
|
#Preview {
|
||||||
|
|||||||
@@ -80,6 +80,10 @@ 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 showingDeleteImagesAlert = false
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
Section("Data Management") {
|
Section("Data Management") {
|
||||||
@@ -117,6 +121,48 @@ 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
|
||||||
|
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
|
// Reset All Data
|
||||||
Button(action: {
|
Button(action: {
|
||||||
showingResetAlert = true
|
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."
|
"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() {
|
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 {
|
struct AppInfoSection: View {
|
||||||
|
|||||||
Reference in New Issue
Block a user