Compare commits

...

9 Commits

Author SHA1 Message Date
30d2b3938e [Android] 1.9.2
All checks were successful
OpenClimb Docker Deploy / build-and-push (push) Successful in 2m29s
2025-10-12 20:41:39 -06:00
405fb06d5d [Android] 1.9.1 - EXIF Fixes 2025-10-12 01:46:16 -06:00
77f8110d85 [Android] 1.9.0 2025-10-11 23:23:24 -06:00
53fa74cc83 iOS Build 23 2025-10-11 18:54:24 -06:00
e7c46634da iOS Build 22 2025-10-10 17:09:23 -06:00
40efd6636f Build 21 2025-10-10 16:32:10 -06:00
719181aa16 iOS Build 20 2025-10-10 13:36:07 -06:00
790b7075c5 [iOS] 1.4.0 - Apple Fitness Integration! 2025-10-10 11:44:33 -06:00
ad8723b8fe One small change 2025-10-09 21:20:08 -06:00
64 changed files with 3791 additions and 2033 deletions

View File

@@ -12,6 +12,7 @@ For Android do one of the following:
For iOS: For iOS:
Download from the AppStore [here](https://apps.apple.com/ca/app/openclimb/id6752592783)! Download from the AppStore [here](https://apps.apple.com/ca/app/openclimb/id6752592783)!
For development builds, sign up for the TestFlight [here](https://testflight.apple.com/join/88RtxV4J)!
## Self-Hosted Sync Server ## Self-Hosted Sync Server

22
android/README.md Normal file
View File

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

View File

@@ -16,8 +16,8 @@ android {
applicationId = "com.atridad.openclimb" applicationId = "com.atridad.openclimb"
minSdk = 31 minSdk = 31
targetSdk = 36 targetSdk = 36
versionCode = 35 versionCode = 39
versionName = "1.8.0" versionName = "1.9.2"
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)
@@ -78,6 +79,9 @@ dependencies {
// Image Loading // Image Loading
implementation(libs.coil.compose) implementation(libs.coil.compose)
// Health Connect
implementation("androidx.health.connect:connect-client:1.1.0-alpha07")
// Testing // Testing
testImplementation(libs.junit) testImplementation(libs.junit)
testImplementation(libs.mockk) testImplementation(libs.mockk)

View File

@@ -10,6 +10,17 @@
<!-- Permission for sync functionality --> <!-- Permission for sync functionality -->
<uses-permission android:name="android.permission.INTERNET" /> <uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<!-- Health Connect permissions -->
<uses-permission android:name="android.permission.health.READ_EXERCISE" />
<uses-permission android:name="android.permission.health.WRITE_EXERCISE" />
<uses-permission android:name="android.permission.health.READ_HEART_RATE" />
<uses-permission android:name="android.permission.health.WRITE_HEART_RATE" />
<uses-permission android:name="android.permission.health.READ_ACTIVE_CALORIES_BURNED" />
<uses-permission android:name="android.permission.health.WRITE_ACTIVE_CALORIES_BURNED" />
<uses-permission android:name="android.permission.health.READ_TOTAL_CALORIES_BURNED" />
<uses-permission android:name="android.permission.health.WRITE_TOTAL_CALORIES_BURNED" />
<!-- Hardware features --> <!-- Hardware features -->
<uses-feature android:name="android.hardware.camera" android:required="false" /> <uses-feature android:name="android.hardware.camera" android:required="false" />
@@ -19,6 +30,18 @@
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" /> <uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_SPECIAL_USE" /> <uses-permission android:name="android.permission.FOREGROUND_SERVICE_SPECIAL_USE" />
<!-- Health Connect queries -->
<queries>
<package android:name="com.google.android.apps.healthdata" />
<intent>
<action android:name="androidx.health.ACTION_SHOW_PERMISSIONS_RATIONALE" />
</intent>
<intent>
<action android:name="android.intent.action.VIEW_PERMISSION_USAGE"/>
<category android:name="android.intent.category.HEALTH_PERMISSIONS"/>
</intent>
</queries>
<application <application
android:allowBackup="true" android:allowBackup="true"
android:dataExtractionRules="@xml/data_extraction_rules" android:dataExtractionRules="@xml/data_extraction_rules"
@@ -40,6 +63,16 @@
<category android:name="android.intent.category.LAUNCHER" /> <category android:name="android.intent.category.LAUNCHER" />
</intent-filter> </intent-filter>
<!-- Health Connect permission rationale handling -->
<intent-filter>
<action android:name="androidx.health.ACTION_SHOW_PERMISSIONS_RATIONALE" />
</intent-filter>
<!-- Permission handling for Android 14 and later -->
<intent-filter>
<action android:name="android.intent.action.VIEW_PERMISSION_USAGE"/>
<category android:name="android.intent.category.HEALTH_PERMISSIONS"/>
</intent-filter>
</activity> </activity>

View File

@@ -0,0 +1,426 @@
package com.atridad.openclimb.data.health
import android.annotation.SuppressLint
import android.content.Context
import android.content.SharedPreferences
import android.util.Log
import androidx.activity.result.contract.ActivityResultContract
import androidx.health.connect.client.HealthConnectClient
import androidx.health.connect.client.PermissionController
import androidx.health.connect.client.permission.HealthPermission
import androidx.health.connect.client.records.ExerciseSessionRecord
import androidx.health.connect.client.records.HeartRateRecord
import androidx.health.connect.client.records.TotalCaloriesBurnedRecord
import androidx.health.connect.client.units.Energy
import com.atridad.openclimb.data.model.ClimbSession
import com.atridad.openclimb.data.model.SessionStatus
import com.atridad.openclimb.utils.DateFormatUtils
import java.time.Duration
import java.time.Instant
import java.time.ZoneOffset
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.flow
/**
* Health Connect manager for OpenClimb that syncs climbing sessions to Samsung Health, Google Fit,
* and other health apps.
*/
@SuppressLint("RestrictedApi")
class HealthConnectManager(private val context: Context) {
private val preferences: SharedPreferences =
context.getSharedPreferences("health_connect_prefs", Context.MODE_PRIVATE)
private val _isEnabled = MutableStateFlow(preferences.getBoolean("enabled", false))
private val _hasPermissions = MutableStateFlow(preferences.getBoolean("permissions", false))
private val _autoSync = MutableStateFlow(preferences.getBoolean("auto_sync", true))
private val _isCompatible = MutableStateFlow(true)
val isEnabled: Flow<Boolean> = _isEnabled.asStateFlow()
val hasPermissions: Flow<Boolean> = _hasPermissions.asStateFlow()
val autoSyncEnabled: Flow<Boolean> = _autoSync.asStateFlow()
val isCompatible: Flow<Boolean> = _isCompatible.asStateFlow()
companion object {
private const val TAG = "HealthConnectManager"
val REQUIRED_PERMISSIONS =
setOf(
HealthPermission.getReadPermission(ExerciseSessionRecord::class),
HealthPermission.getWritePermission(ExerciseSessionRecord::class),
HealthPermission.getReadPermission(HeartRateRecord::class),
HealthPermission.getWritePermission(HeartRateRecord::class),
HealthPermission.getReadPermission(TotalCaloriesBurnedRecord::class),
HealthPermission.getWritePermission(TotalCaloriesBurnedRecord::class)
)
}
private val healthConnectClient by lazy {
try {
HealthConnectClient.getOrCreate(context)
} catch (e: Exception) {
Log.e(TAG, "Failed to create Health Connect client", e)
_isCompatible.value = false
null
}
}
/** Check if Health Connect is available on this device */
fun isHealthConnectAvailable(): Flow<Boolean> = flow {
try {
if (!_isCompatible.value) {
emit(false)
return@flow
}
val status = HealthConnectClient.getSdkStatus(context)
emit(status == HealthConnectClient.SDK_AVAILABLE)
} catch (e: Exception) {
Log.e(TAG, "Error checking Health Connect availability", e)
_isCompatible.value = false
emit(false)
}
}
/** Enable or disable Health Connect integration */
fun setEnabled(enabled: Boolean) {
preferences.edit().putBoolean("enabled", enabled).apply()
_isEnabled.value = enabled
if (!enabled) {
setPermissionsGranted(false)
}
}
/** Update the permissions granted state */
fun setPermissionsGranted(granted: Boolean) {
preferences.edit().putBoolean("permissions", granted).apply()
_hasPermissions.value = granted
}
/** Enable or disable auto-sync */
fun setAutoSyncEnabled(enabled: Boolean) {
preferences.edit().putBoolean("auto_sync", enabled).apply()
_autoSync.value = enabled
}
/** Check if all required permissions are granted */
suspend fun hasAllPermissions(): Boolean {
return try {
if (!_isCompatible.value || healthConnectClient == null) {
return false
}
val grantedPermissions =
healthConnectClient!!.permissionController.getGrantedPermissions()
val hasAll =
REQUIRED_PERMISSIONS.all { permission ->
grantedPermissions.contains(permission)
}
setPermissionsGranted(hasAll)
hasAll
} catch (e: Exception) {
Log.e(TAG, "Error checking permissions", e)
setPermissionsGranted(false)
false
}
}
/** Check if Health Connect is ready for use */
suspend fun isReady(): Boolean {
return try {
if (!_isEnabled.value || !_isCompatible.value || healthConnectClient == null)
return false
val isAvailable =
HealthConnectClient.getSdkStatus(context) == HealthConnectClient.SDK_AVAILABLE
val hasPerms = if (isAvailable) hasAllPermissions() else false
isAvailable && hasPerms
} catch (e: Exception) {
Log.e(TAG, "Error checking Health Connect readiness", e)
false
}
}
/** Get permission request contract */
fun getPermissionRequestContract(): ActivityResultContract<Set<String>, Set<String>> {
return PermissionController.createRequestPermissionResultContract()
}
/** Test Health Connect functionality */
fun testHealthConnectSync(): String {
val results = mutableListOf<String>()
results.add("=== Health Connect Debug Test ===")
try {
// Check availability synchronously
val packageManager = context.packageManager
val healthConnectPackages =
listOf(
"com.google.android.apps.healthdata",
"com.android.health.connect",
"androidx.health.connect"
)
val available =
healthConnectPackages.any { packageName ->
try {
packageManager.getPackageInfo(packageName, 0)
true
} catch (e: Exception) {
false
}
}
results.add("Available: $available")
// Check enabled state
results.add("Enabled in settings: ${_isEnabled.value}")
// Check permissions (simplified)
val hasPerms = _hasPermissions.value
results.add("Has permissions: $hasPerms")
// Check compatibility
results.add("API Compatible: ${_isCompatible.value}")
val ready = _isEnabled.value && _isCompatible.value && available && hasPerms
results.add("Ready to sync: $ready")
if (ready) {
results.add("Health Connect is connected!")
} else {
results.add("❌ Health Connect not ready")
if (!available) results.add("- Health Connect not available on device")
if (!_isEnabled.value) results.add("- Not enabled in OpenClimb settings")
if (!hasPerms) results.add("- Permissions not granted")
if (!_isCompatible.value) results.add("- API compatibility issues")
}
} catch (e: Exception) {
results.add("Test failed with error: ${e.message}")
Log.e(TAG, "Health Connect test failed", e)
}
return results.joinToString("\n")
}
/** Get required permissions as strings */
fun getRequiredPermissions(): Set<String> {
return try {
REQUIRED_PERMISSIONS.map { it.toString() }.toSet()
} catch (e: Exception) {
Log.e(TAG, "Error getting required permissions", e)
emptySet()
}
}
/** Sync a completed climbing session to Health Connect */
@SuppressLint("RestrictedApi")
suspend fun syncClimbingSession(
session: ClimbSession,
gymName: String,
attemptCount: Int = 0
): Result<Unit> {
return try {
if (!isReady()) {
return Result.failure(IllegalStateException("Health Connect not ready"))
}
if (session.status != SessionStatus.COMPLETED) {
return Result.failure(
IllegalArgumentException("Only completed sessions can be synced")
)
}
val startTime = session.startTime?.let { DateFormatUtils.parseISO8601(it) }
val endTime = session.endTime?.let { DateFormatUtils.parseISO8601(it) }
if (startTime == null || endTime == null) {
return Result.failure(
IllegalArgumentException("Session must have valid start and end times")
)
}
Log.d(TAG, "Attempting to sync session '${session.id}' to Health Connect...")
val records = mutableListOf<androidx.health.connect.client.records.Record>()
try {
val exerciseSession =
ExerciseSessionRecord(
startTime = startTime,
startZoneOffset =
ZoneOffset.systemDefault().rules.getOffset(startTime),
endTime = endTime,
endZoneOffset = ZoneOffset.systemDefault().rules.getOffset(endTime),
exerciseType =
ExerciseSessionRecord.EXERCISE_TYPE_STRENGTH_TRAINING,
title = "Rock Climbing at $gymName"
)
records.add(exerciseSession)
} catch (e: Exception) {
Log.w(TAG, "Failed to create exercise session record", e)
}
try {
val durationMinutes = Duration.between(startTime, endTime).toMinutes()
val estimatedCalories = estimateCaloriesForClimbing(durationMinutes, attemptCount)
if (estimatedCalories > 0) {
val caloriesRecord =
TotalCaloriesBurnedRecord(
startTime = startTime,
startZoneOffset =
ZoneOffset.systemDefault().rules.getOffset(startTime),
endTime = endTime,
endZoneOffset =
ZoneOffset.systemDefault().rules.getOffset(endTime),
energy = Energy.calories(estimatedCalories)
)
records.add(caloriesRecord)
}
} catch (e: Exception) {
Log.w(TAG, "Failed to create calories record", e)
}
try {
val heartRateRecord = createHeartRateRecord(startTime, endTime, attemptCount)
heartRateRecord?.let { records.add(it) }
} catch (e: Exception) {
Log.w(TAG, "Failed to create heart rate record", e)
}
if (records.isNotEmpty() && healthConnectClient != null) {
Log.d(TAG, "Writing ${records.size} records to Health Connect...")
healthConnectClient!!.insertRecords(records)
Log.i(
TAG,
"Successfully synced ${records.size} records for session '${session.id}' to Health Connect"
)
preferences
.edit()
.putString("last_sync_success", DateFormatUtils.nowISO8601())
.apply()
} else {
val reason =
when {
records.isEmpty() -> "No records created"
healthConnectClient == null -> "Health Connect client unavailable"
else -> "Unknown reason"
}
Log.w(TAG, "Sync failed for session '${session.id}': $reason")
return Result.failure(Exception("Sync failed: $reason"))
}
Result.success(Unit)
} catch (e: Exception) {
Log.e(TAG, "Error syncing climbing session to Health Connect", e)
Result.failure(e)
}
}
/** Auto-sync a session if enabled */
suspend fun autoSyncSession(
session: ClimbSession,
gymName: String,
attemptCount: Int = 0
): Result<Unit> {
return if (_autoSync.value && isReady()) {
Log.d(TAG, "Auto-syncing session '${session.id}' to Health Connect...")
syncClimbingSession(session, gymName, attemptCount)
} else {
val reason =
when {
!_autoSync.value -> "auto-sync disabled"
!isReady() -> "Health Connect not ready"
else -> "unknown reason"
}
Log.d(TAG, "Auto-sync skipped for session '${session.id}': $reason")
Result.success(Unit)
}
}
/** Estimate calories burned during climbing */
private fun estimateCaloriesForClimbing(durationMinutes: Long, attemptCount: Int): Double {
val baseCaloriesPerMinute = 8.0
val intensityMultiplier =
when {
attemptCount >= 20 -> 1.3
attemptCount >= 10 -> 1.1
else -> 0.9
}
return durationMinutes * baseCaloriesPerMinute * intensityMultiplier
}
/** Create heart rate data */
@SuppressLint("RestrictedApi")
private fun createHeartRateRecord(
startTime: Instant,
endTime: Instant,
attemptCount: Int
): HeartRateRecord? {
return try {
val samples = mutableListOf<HeartRateRecord.Sample>()
val intervalMinutes = 5L
val baseHeartRate =
when {
attemptCount >= 20 -> 155L
attemptCount >= 10 -> 145L
else -> 135L
}
var currentTime = startTime
while (currentTime.isBefore(endTime)) {
val variation = (-15..15).random()
val heartRate = (baseHeartRate + variation).coerceIn(110L, 180L)
samples.add(HeartRateRecord.Sample(time = currentTime, beatsPerMinute = heartRate))
currentTime = currentTime.plusSeconds(intervalMinutes * 60)
}
if (samples.isEmpty()) return null
HeartRateRecord(
startTime = startTime,
startZoneOffset = ZoneOffset.systemDefault().rules.getOffset(startTime),
endTime = endTime,
endZoneOffset = ZoneOffset.systemDefault().rules.getOffset(endTime),
samples = samples
)
} catch (e: Exception) {
Log.e(TAG, "Error creating heart rate record", e)
null
}
}
/** Reset all preferences */
fun reset() {
preferences.edit().clear().apply()
_isEnabled.value = false
_hasPermissions.value = false
_autoSync.value = true
}
/** Check if ready for use */
fun isReadySync(): Boolean {
return _isEnabled.value && _hasPermissions.value
}
/** Get last successful sync timestamp */
fun getLastSyncSuccess(): String? {
return preferences.getString("last_sync_success", null)
}
/** Get detailed status */
fun getDetailedStatus(): Map<String, String> {
return mapOf(
"enabled" to _isEnabled.value.toString(),
"hasPermissions" to _hasPermissions.value.toString(),
"autoSync" to _autoSync.value.toString(),
"compatible" to _isCompatible.value.toString(),
"lastSyncSuccess" to (getLastSyncSuccess() ?: "never")
)
}
}

View File

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

View File

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

View File

@@ -207,7 +207,7 @@ data class DifficultyGrade(val system: DifficultySystem, val grade: String, val
private fun compareVScaleGrades(grade1: String, grade2: String): Int { private fun compareVScaleGrades(grade1: String, grade2: String): Int {
if (grade1 == "VB" && grade2 != "VB") return -1 if (grade1 == "VB" && grade2 != "VB") return -1
if (grade2 == "VB" && grade1 != "VB") return 1 if (grade2 == "VB" && grade1 != "VB") return 1
if (grade1 == "VB" && grade2 == "VB") return 0 if (grade1 == "VB") return 0
val num1 = grade1.removePrefix("V").toIntOrNull() ?: 0 val num1 = grade1.removePrefix("V").toIntOrNull() ?: 0
val num2 = grade2.removePrefix("V").toIntOrNull() ?: 0 val num2 = grade2.removePrefix("V").toIntOrNull() ?: 0

View File

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

View File

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

View File

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

View File

@@ -45,7 +45,7 @@ fun OpenClimbApp(
val repository = remember { ClimbRepository(database, context) } val repository = remember { ClimbRepository(database, context) }
val syncService = remember { SyncService(context, repository) } val syncService = remember { SyncService(context, repository) }
val viewModel: ClimbViewModel = val viewModel: ClimbViewModel =
viewModel(factory = ClimbViewModelFactory(repository, syncService)) viewModel(factory = ClimbViewModelFactory(repository, syncService, context))
var showNotificationPermissionDialog by remember { mutableStateOf(false) } var showNotificationPermissionDialog by remember { mutableStateOf(false) }
var hasCheckedNotificationPermission by remember { mutableStateOf(false) } var hasCheckedNotificationPermission by remember { mutableStateOf(false) }

View File

@@ -3,7 +3,6 @@ package com.atridad.openclimb.ui.components
import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.clickable import androidx.compose.foundation.clickable
import androidx.compose.foundation.gestures.detectTransformGestures
import androidx.compose.foundation.layout.* import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyRow import androidx.compose.foundation.lazy.LazyRow
import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.foundation.lazy.itemsIndexed
@@ -20,95 +19,67 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.window.Dialog import androidx.compose.ui.window.Dialog
import androidx.compose.ui.window.DialogProperties import androidx.compose.ui.window.DialogProperties
import coil.compose.AsyncImage
import com.atridad.openclimb.utils.ImageUtils
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
@OptIn(ExperimentalFoundationApi::class) @OptIn(ExperimentalFoundationApi::class)
@Composable @Composable
fun FullscreenImageViewer( fun FullscreenImageViewer(imagePaths: List<String>, initialIndex: Int = 0, onDismiss: () -> Unit) {
imagePaths: List<String>,
initialIndex: Int = 0,
onDismiss: () -> Unit
) {
val context = LocalContext.current val context = LocalContext.current
val pagerState = rememberPagerState( val pagerState = rememberPagerState(initialPage = initialIndex, pageCount = { imagePaths.size })
initialPage = initialIndex,
pageCount = { imagePaths.size }
)
val thumbnailListState = rememberLazyListState() val thumbnailListState = rememberLazyListState()
val coroutineScope = rememberCoroutineScope() val coroutineScope = rememberCoroutineScope()
// Auto-scroll thumbnail list to center current image // Auto-scroll thumbnail list to center current image
LaunchedEffect(pagerState.currentPage) { LaunchedEffect(pagerState.currentPage) {
thumbnailListState.animateScrollToItem( thumbnailListState.animateScrollToItem(index = pagerState.currentPage, scrollOffset = -200)
index = pagerState.currentPage,
scrollOffset = -200
)
} }
Dialog( Dialog(
onDismissRequest = onDismiss, onDismissRequest = onDismiss,
properties = DialogProperties( properties =
usePlatformDefaultWidth = false, DialogProperties(
decorFitsSystemWindows = false usePlatformDefaultWidth = false,
) decorFitsSystemWindows = false
)
) { ) {
Box( Box(modifier = Modifier.fillMaxSize().background(Color.Black)) {
modifier = Modifier
.fillMaxSize()
.background(Color.Black)
) {
// Main image pager // Main image pager
HorizontalPager( HorizontalPager(state = pagerState, modifier = Modifier.fillMaxSize()) { page ->
state = pagerState, OrientationAwareImage(
modifier = Modifier.fillMaxSize() imagePath = imagePaths[page],
) { page -> contentDescription = "Full screen image",
ZoomableImage( modifier = Modifier.fillMaxSize(),
imagePath = imagePaths[page], contentScale = ContentScale.Fit
modifier = Modifier.fillMaxSize()
) )
} }
// Close button // Close button
IconButton( IconButton(
onClick = onDismiss, onClick = onDismiss,
modifier = Modifier modifier =
.align(Alignment.TopEnd) Modifier.align(Alignment.TopEnd)
.padding(16.dp) .padding(16.dp)
.background( .background(Color.Black.copy(alpha = 0.5f), CircleShape)
Color.Black.copy(alpha = 0.5f), ) { Icon(Icons.Default.Close, contentDescription = "Close", tint = Color.White) }
CircleShape
)
) {
Icon(
Icons.Default.Close,
contentDescription = "Close",
tint = Color.White
)
}
// Image counter // Image counter
if (imagePaths.size > 1) { if (imagePaths.size > 1) {
Card( Card(
modifier = Modifier modifier = Modifier.align(Alignment.TopCenter).padding(16.dp),
.align(Alignment.TopCenter) colors =
.padding(16.dp), CardDefaults.cardColors(
colors = CardDefaults.cardColors( containerColor = Color.Black.copy(alpha = 0.7f)
containerColor = Color.Black.copy(alpha = 0.7f) )
)
) { ) {
Text( Text(
text = "${pagerState.currentPage + 1} / ${imagePaths.size}", text = "${pagerState.currentPage + 1} / ${imagePaths.size}",
color = Color.White, color = Color.White,
modifier = Modifier.padding(horizontal = 12.dp, vertical = 6.dp) modifier = Modifier.padding(horizontal = 12.dp, vertical = 6.dp)
) )
} }
} }
@@ -116,44 +87,46 @@ fun FullscreenImageViewer(
// Thumbnail strip (if multiple images) // Thumbnail strip (if multiple images)
if (imagePaths.size > 1) { if (imagePaths.size > 1) {
Card( Card(
modifier = Modifier modifier =
.align(Alignment.BottomCenter) Modifier.align(Alignment.BottomCenter)
.fillMaxWidth() .fillMaxWidth()
.padding(16.dp), .padding(16.dp),
colors = CardDefaults.cardColors( colors =
containerColor = Color.Black.copy(alpha = 0.7f) CardDefaults.cardColors(
) containerColor = Color.Black.copy(alpha = 0.7f)
)
) { ) {
LazyRow( LazyRow(
state = thumbnailListState, state = thumbnailListState,
modifier = Modifier.padding(8.dp), modifier = Modifier.padding(8.dp),
horizontalArrangement = Arrangement.spacedBy(8.dp), horizontalArrangement = Arrangement.spacedBy(8.dp),
contentPadding = PaddingValues(horizontal = 8.dp) contentPadding = PaddingValues(horizontal = 8.dp)
) { ) {
itemsIndexed(imagePaths) { index, imagePath -> itemsIndexed(imagePaths) { index, imagePath ->
val imageFile = ImageUtils.getImageFile(context, imagePath)
val isSelected = index == pagerState.currentPage val isSelected = index == pagerState.currentPage
AsyncImage( OrientationAwareImage(
model = imageFile, imagePath = imagePath,
contentDescription = "Thumbnail ${index + 1}", contentDescription = "Thumbnail ${index + 1}",
modifier = Modifier modifier =
.size(60.dp) Modifier.size(60.dp)
.clip(RoundedCornerShape(8.dp)) .clip(RoundedCornerShape(8.dp))
.clickable { .clickable {
coroutineScope.launch { coroutineScope.launch {
pagerState.animateScrollToPage(index) pagerState.animateScrollToPage(index)
} }
} }
.then( .then(
if (isSelected) { if (isSelected) {
Modifier.background( Modifier.background(
Color.White.copy(alpha = 0.3f), Color.White.copy(
RoundedCornerShape(8.dp) alpha = 0.3f
) ),
} else Modifier RoundedCornerShape(8.dp)
), )
contentScale = ContentScale.Crop } else Modifier
),
contentScale = ContentScale.Crop
) )
} }
} }
@@ -162,48 +135,3 @@ fun FullscreenImageViewer(
} }
} }
} }
@Composable
private fun ZoomableImage(
imagePath: String,
modifier: Modifier = Modifier
) {
val context = LocalContext.current
val imageFile = ImageUtils.getImageFile(context, imagePath)
var scale by remember { mutableFloatStateOf(1f) }
var offsetX by remember { mutableFloatStateOf(0f) }
var offsetY by remember { mutableFloatStateOf(0f) }
Box(
modifier = modifier
.pointerInput(Unit) {
detectTransformGestures(
onGesture = { _, pan, zoom, _ ->
scale = (scale * zoom).coerceIn(0.5f, 5f)
val maxOffsetX = (size.width * (scale - 1)) / 2
val maxOffsetY = (size.height * (scale - 1)) / 2
offsetX = (offsetX + pan.x).coerceIn(-maxOffsetX, maxOffsetX)
offsetY = (offsetY + pan.y).coerceIn(-maxOffsetY, maxOffsetY)
}
)
},
contentAlignment = Alignment.Center
) {
AsyncImage(
model = imageFile,
contentDescription = "Full screen image",
modifier = Modifier
.fillMaxSize()
.graphicsLayer(
scaleX = scale,
scaleY = scale,
translationX = offsetX,
translationY = offsetY
),
contentScale = ContentScale.Fit
)
}
}

View File

@@ -12,36 +12,29 @@ import androidx.compose.ui.draw.clip
import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import coil.compose.AsyncImage
import com.atridad.openclimb.utils.ImageUtils
@Composable @Composable
fun ImageDisplay( fun ImageDisplay(
imagePaths: List<String>, imagePaths: List<String>,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
imageSize: Int = 120, imageSize: Int = 120,
onImageClick: ((Int) -> Unit)? = null onImageClick: ((Int) -> Unit)? = null
) { ) {
val context = LocalContext.current val context = LocalContext.current
if (imagePaths.isNotEmpty()) { if (imagePaths.isNotEmpty()) {
LazyRow( LazyRow(modifier = modifier, horizontalArrangement = Arrangement.spacedBy(8.dp)) {
modifier = modifier,
horizontalArrangement = Arrangement.spacedBy(8.dp)
) {
itemsIndexed(imagePaths) { index, imagePath -> itemsIndexed(imagePaths) { index, imagePath ->
val imageFile = ImageUtils.getImageFile(context, imagePath) OrientationAwareImage(
imagePath = imagePath,
AsyncImage( contentDescription = "Problem photo",
model = imageFile, modifier =
contentDescription = "Problem photo", Modifier.size(imageSize.dp)
modifier = Modifier .clip(RoundedCornerShape(8.dp))
.size(imageSize.dp) .clickable(enabled = onImageClick != null) {
.clip(RoundedCornerShape(8.dp)) onImageClick?.invoke(index)
.clickable(enabled = onImageClick != null) { },
onImageClick?.invoke(index) contentScale = ContentScale.Crop
},
contentScale = ContentScale.Crop
) )
} }
} }
@@ -50,26 +43,22 @@ fun ImageDisplay(
@Composable @Composable
fun ImageDisplaySection( fun ImageDisplaySection(
imagePaths: List<String>, imagePaths: List<String>,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
title: String = "Photos", title: String = "Photos",
onImageClick: ((Int) -> Unit)? = null onImageClick: ((Int) -> Unit)? = null
) { ) {
if (imagePaths.isNotEmpty()) { if (imagePaths.isNotEmpty()) {
Column(modifier = modifier) { Column(modifier = modifier) {
Text( Text(
text = title, text = title,
style = MaterialTheme.typography.titleMedium, style = MaterialTheme.typography.titleMedium,
color = MaterialTheme.colorScheme.onSurface color = MaterialTheme.colorScheme.onSurface
) )
Spacer(modifier = Modifier.height(8.dp)) Spacer(modifier = Modifier.height(8.dp))
ImageDisplay( ImageDisplay(imagePaths = imagePaths, imageSize = 120, onImageClick = onImageClick)
imagePaths = imagePaths,
imageSize = 120,
onImageClick = onImageClick
)
} }
} }
} }

View File

@@ -25,7 +25,6 @@ import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.core.content.ContextCompat import androidx.core.content.ContextCompat
import androidx.core.content.FileProvider import androidx.core.content.FileProvider
import coil.compose.AsyncImage
import com.atridad.openclimb.utils.ImageUtils import com.atridad.openclimb.utils.ImageUtils
import java.io.File import java.io.File
import java.text.SimpleDateFormat import java.text.SimpleDateFormat
@@ -56,7 +55,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 +75,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
@@ -259,8 +258,8 @@ private fun ImageItem(imagePath: String, onRemove: () -> Unit, modifier: Modifie
val imageFile = ImageUtils.getImageFile(context, imagePath) val imageFile = ImageUtils.getImageFile(context, imagePath)
Box(modifier = modifier.size(80.dp)) { Box(modifier = modifier.size(80.dp)) {
AsyncImage( OrientationAwareImage(
model = imageFile, imagePath = imagePath,
contentDescription = "Problem photo", contentDescription = "Problem photo",
modifier = Modifier.fillMaxSize().clip(RoundedCornerShape(8.dp)), modifier = Modifier.fillMaxSize().clip(RoundedCornerShape(8.dp)),
contentScale = ContentScale.Crop contentScale = ContentScale.Crop

View File

@@ -0,0 +1,149 @@
package com.atridad.openclimb.ui.components
import android.graphics.BitmapFactory
import android.graphics.Matrix
import androidx.compose.foundation.Image
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.runtime.*
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.asImageBitmap
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalContext
import androidx.exifinterface.media.ExifInterface
import com.atridad.openclimb.utils.ImageUtils
import java.io.File
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
@Composable
fun OrientationAwareImage(
imagePath: String,
contentDescription: String? = null,
modifier: Modifier = Modifier,
contentScale: ContentScale = ContentScale.Fit
) {
val context = LocalContext.current
var imageBitmap by
remember(imagePath) { mutableStateOf<androidx.compose.ui.graphics.ImageBitmap?>(null) }
var isLoading by remember(imagePath) { mutableStateOf(true) }
LaunchedEffect(imagePath) {
isLoading = true
val bitmap =
withContext(Dispatchers.IO) {
try {
val imageFile = ImageUtils.getImageFile(context, imagePath)
if (!imageFile.exists()) return@withContext null
val originalBitmap =
BitmapFactory.decodeFile(imageFile.absolutePath)
?: return@withContext null
val correctedBitmap = correctImageOrientation(imageFile, originalBitmap)
correctedBitmap.asImageBitmap()
} catch (e: Exception) {
null
}
}
imageBitmap = bitmap
isLoading = false
}
Box(modifier = modifier) {
if (isLoading) {
CircularProgressIndicator(modifier = Modifier.fillMaxSize())
} else {
imageBitmap?.let { bitmap ->
Image(
bitmap = bitmap,
contentDescription = contentDescription,
modifier = Modifier.fillMaxSize(),
contentScale = contentScale
)
}
}
}
}
private fun correctImageOrientation(
imageFile: File,
bitmap: android.graphics.Bitmap
): android.graphics.Bitmap {
return try {
val exif = ExifInterface(imageFile.absolutePath)
val orientation =
exif.getAttributeInt(
ExifInterface.TAG_ORIENTATION,
ExifInterface.ORIENTATION_NORMAL
)
val matrix = Matrix()
var needsTransform = false
when (orientation) {
ExifInterface.ORIENTATION_ROTATE_90 -> {
matrix.postRotate(90f)
needsTransform = true
}
ExifInterface.ORIENTATION_ROTATE_180 -> {
matrix.postRotate(180f)
needsTransform = true
}
ExifInterface.ORIENTATION_ROTATE_270 -> {
matrix.postRotate(270f)
needsTransform = true
}
ExifInterface.ORIENTATION_FLIP_HORIZONTAL -> {
matrix.postScale(-1f, 1f)
needsTransform = true
}
ExifInterface.ORIENTATION_FLIP_VERTICAL -> {
matrix.postScale(1f, -1f)
needsTransform = true
}
ExifInterface.ORIENTATION_TRANSPOSE -> {
matrix.postRotate(90f)
matrix.postScale(-1f, 1f)
needsTransform = true
}
ExifInterface.ORIENTATION_TRANSVERSE -> {
matrix.postRotate(-90f)
matrix.postScale(-1f, 1f)
needsTransform = true
}
else -> {
if (orientation == ExifInterface.ORIENTATION_UNDEFINED || orientation == 0) {
if (imageFile.name.startsWith("problem_") &&
imageFile.name.contains("_") &&
imageFile.name.endsWith(".jpg")
) {
matrix.postRotate(90f)
needsTransform = true
}
}
}
}
if (!needsTransform) {
bitmap
} else {
val rotatedBitmap =
android.graphics.Bitmap.createBitmap(
bitmap,
0,
0,
bitmap.width,
bitmap.height,
matrix,
true
)
if (rotatedBitmap != bitmap) {
bitmap.recycle()
}
rotatedBitmap
}
} catch (e: Exception) {
bitmap
}
}

View File

@@ -0,0 +1,440 @@
package com.atridad.openclimb.ui.health
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.*
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import com.atridad.openclimb.data.health.HealthConnectManager
import kotlinx.coroutines.launch
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun HealthConnectCard(modifier: Modifier = Modifier) {
val context = LocalContext.current
val healthConnectManager = remember { HealthConnectManager(context) }
val coroutineScope = rememberCoroutineScope()
// State tracking
var isHealthConnectAvailable by remember { mutableStateOf(false) }
var isLoading by remember { mutableStateOf(true) }
var errorMessage by remember { mutableStateOf<String?>(null) }
// Collect flows
val isEnabled by healthConnectManager.isEnabled.collectAsState(initial = false)
val hasPermissions by healthConnectManager.hasPermissions.collectAsState(initial = false)
val autoSyncEnabled by healthConnectManager.autoSyncEnabled.collectAsState(initial = true)
val isCompatible by healthConnectManager.isCompatible.collectAsState(initial = true)
// Permission launcher
val permissionLauncher =
rememberLauncherForActivityResult(
contract = healthConnectManager.getPermissionRequestContract()
) { grantedPermissions ->
coroutineScope.launch {
val allGranted = healthConnectManager.hasAllPermissions()
if (!allGranted) {
errorMessage =
"Some Health Connect permissions were not granted. Please grant all permissions to enable syncing."
} else {
errorMessage = null
}
}
}
// Check Health Connect availability on first load
LaunchedEffect(Unit) {
coroutineScope.launch {
try {
healthConnectManager.isHealthConnectAvailable().collect { available ->
isHealthConnectAvailable = available
isLoading = false
if (!available && isCompatible) {
errorMessage = "Health Connect is not available on this device"
} else if (!isCompatible) {
errorMessage =
"Health Connect API compatibility issue. Please update your device or the app."
}
}
} catch (e: Exception) {
isLoading = false
errorMessage = "Error checking Health Connect availability: ${e.message}"
}
}
}
Card(
modifier = modifier.fillMaxWidth(),
shape = RoundedCornerShape(16.dp),
colors =
CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.surfaceVariant
)
) {
Column(
modifier = Modifier.fillMaxWidth().padding(20.dp),
horizontalAlignment = Alignment.CenterHorizontally
) {
// Header with icon and title
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically
) {
Icon(
imageVector = Icons.Default.HealthAndSafety,
contentDescription = null,
modifier = Modifier.size(32.dp),
tint =
if (isHealthConnectAvailable && isEnabled && hasPermissions) {
MaterialTheme.colorScheme.primary
} else {
MaterialTheme.colorScheme.onSurfaceVariant
}
)
Spacer(modifier = Modifier.width(12.dp))
Column(modifier = Modifier.weight(1f)) {
Text(
text = "Health Connect",
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.Bold,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
Text(
text =
when {
isLoading -> "Checking availability..."
!isCompatible -> "API Issue"
!isHealthConnectAvailable -> "Not available"
isEnabled && hasPermissions -> "Connected"
isEnabled && !hasPermissions -> "Needs permissions"
else -> "Disabled"
},
style = MaterialTheme.typography.bodySmall,
color =
when {
isLoading ->
MaterialTheme.colorScheme.onSurfaceVariant.copy(
alpha = 0.7f
)
!isCompatible -> MaterialTheme.colorScheme.error
!isHealthConnectAvailable -> MaterialTheme.colorScheme.error
isEnabled && hasPermissions ->
MaterialTheme.colorScheme.primary
isEnabled && !hasPermissions ->
MaterialTheme.colorScheme.tertiary
else ->
MaterialTheme.colorScheme.onSurfaceVariant.copy(
alpha = 0.7f
)
}
)
}
// Main toggle switch
Switch(
checked = isEnabled,
onCheckedChange = { enabled ->
if (enabled && isHealthConnectAvailable) {
healthConnectManager.setEnabled(true)
coroutineScope.launch {
try {
val permissionSet =
healthConnectManager.getRequiredPermissions()
if (permissionSet.isNotEmpty()) {
permissionLauncher.launch(permissionSet)
}
} catch (e: Exception) {
errorMessage = "Error requesting permissions: ${e.message}"
}
}
} else {
healthConnectManager.setEnabled(false)
errorMessage = null
}
},
enabled = isHealthConnectAvailable && !isLoading && isCompatible
)
}
if (isEnabled) {
Spacer(modifier = Modifier.height(16.dp))
Card(
shape = RoundedCornerShape(12.dp),
colors =
CardDefaults.cardColors(
containerColor =
if (hasPermissions) {
MaterialTheme.colorScheme.primaryContainer.copy(
alpha = 0.3f
)
} else {
MaterialTheme.colorScheme.errorContainer.copy(
alpha = 0.3f
)
}
)
) {
Column(modifier = Modifier.fillMaxWidth().padding(16.dp)) {
Row(verticalAlignment = Alignment.CenterVertically) {
Icon(
imageVector =
if (hasPermissions) Icons.Default.CheckCircle
else Icons.Default.Warning,
contentDescription = null,
modifier = Modifier.size(20.dp),
tint =
if (hasPermissions) {
MaterialTheme.colorScheme.primary
} else {
MaterialTheme.colorScheme.error
}
)
Spacer(modifier = Modifier.width(8.dp))
Text(
text =
if (hasPermissions) "Ready to sync"
else "Permissions needed",
style = MaterialTheme.typography.bodyMedium,
fontWeight = FontWeight.Medium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
if (!hasPermissions) {
Spacer(modifier = Modifier.height(8.dp))
Text(
text =
"Grant Health Connect permissions to sync your climbing sessions",
style = MaterialTheme.typography.bodySmall,
color =
MaterialTheme.colorScheme.onSurfaceVariant.copy(
alpha = 0.8f
)
)
Spacer(modifier = Modifier.height(8.dp))
OutlinedButton(
onClick = {
coroutineScope.launch {
try {
val permissionSet =
healthConnectManager
.getRequiredPermissions()
if (permissionSet.isNotEmpty()) {
permissionLauncher.launch(permissionSet)
}
} catch (e: Exception) {
errorMessage =
"Error requesting permissions: ${e.message}"
}
}
},
modifier = Modifier.fillMaxWidth()
) { Text("Grant Permissions") }
}
}
}
if (hasPermissions) {
Spacer(modifier = Modifier.height(12.dp))
Card(
shape = RoundedCornerShape(12.dp),
colors =
CardDefaults.cardColors(
containerColor =
MaterialTheme.colorScheme.surfaceVariant.copy(
alpha = 0.3f
)
)
) {
Row(
modifier = Modifier.fillMaxWidth().padding(16.dp),
verticalAlignment = Alignment.CenterVertically
) {
Column(modifier = Modifier.weight(1f)) {
Text(
text = "Auto-sync sessions",
style = MaterialTheme.typography.bodyMedium,
fontWeight = FontWeight.Medium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
Text(
text = "Automatically sync completed climbing sessions",
style = MaterialTheme.typography.bodySmall,
color =
MaterialTheme.colorScheme.onSurfaceVariant.copy(
alpha = 0.7f
)
)
}
Switch(
checked = autoSyncEnabled,
onCheckedChange = { enabled ->
healthConnectManager.setAutoSyncEnabled(enabled)
}
)
}
}
}
} else {
Spacer(modifier = Modifier.height(16.dp))
Text(
text =
"Sync your climbing sessions to Samsung Health, Google Fit, and other fitness apps through Health Connect.",
style = MaterialTheme.typography.bodyMedium,
textAlign = TextAlign.Center,
color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.8f)
)
}
errorMessage?.let { error ->
Spacer(modifier = Modifier.height(12.dp))
Card(
shape = RoundedCornerShape(8.dp),
colors =
CardDefaults.cardColors(
containerColor =
MaterialTheme.colorScheme.errorContainer.copy(
alpha = 0.5f
)
)
) {
Row(
modifier = Modifier.fillMaxWidth().padding(12.dp),
verticalAlignment = Alignment.CenterVertically
) {
Icon(
imageVector = Icons.Default.Warning,
contentDescription = null,
modifier = Modifier.size(16.dp),
tint = MaterialTheme.colorScheme.error
)
Spacer(modifier = Modifier.width(8.dp))
Text(
text = error,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onErrorContainer
)
}
}
}
if (isEnabled) {
Spacer(modifier = Modifier.height(12.dp))
var testResult by remember { mutableStateOf<String?>(null) }
var isTestRunning by remember { mutableStateOf(false) }
OutlinedButton(
onClick = {
isTestRunning = true
coroutineScope.launch {
try {
testResult = healthConnectManager.testHealthConnectSync()
} catch (e: Exception) {
testResult = "Test failed: ${e.message}"
} finally {
isTestRunning = false
}
}
},
enabled = !isTestRunning,
modifier = Modifier.fillMaxWidth()
) {
if (isTestRunning) {
CircularProgressIndicator(
modifier = Modifier.size(16.dp),
strokeWidth = 2.dp
)
Spacer(modifier = Modifier.width(8.dp))
}
Text(if (isTestRunning) "Testing..." else "Test Connection")
}
testResult?.let { result ->
Spacer(modifier = Modifier.height(8.dp))
Card(
shape = RoundedCornerShape(8.dp),
colors =
CardDefaults.cardColors(
containerColor =
MaterialTheme.colorScheme.surfaceVariant.copy(
alpha = 0.5f
)
)
) {
Column(modifier = Modifier.fillMaxWidth().padding(12.dp)) {
Text(
text = "Debug Results:",
style = MaterialTheme.typography.labelSmall,
fontWeight = FontWeight.Bold,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
Spacer(modifier = Modifier.height(4.dp))
Text(
text = result,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
fontFamily = androidx.compose.ui.text.font.FontFamily.Monospace
)
}
}
}
}
}
}
}
@Composable
fun HealthConnectStatusBanner(isConnected: Boolean, modifier: Modifier = Modifier) {
if (isConnected) {
Card(
modifier = modifier.fillMaxWidth(),
shape = RoundedCornerShape(8.dp),
colors =
CardDefaults.cardColors(
containerColor =
MaterialTheme.colorScheme.primaryContainer.copy(
alpha = 0.5f
)
)
) {
Row(
modifier = Modifier.fillMaxWidth().padding(12.dp),
verticalAlignment = Alignment.CenterVertically
) {
Icon(
imageVector = Icons.Default.CloudDone,
contentDescription = null,
modifier = Modifier.size(16.dp),
tint = MaterialTheme.colorScheme.primary
)
Spacer(modifier = Modifier.width(8.dp))
Text(
text = "Health Connect active - sessions will sync automatically",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onPrimaryContainer
)
}
}
}
}

View File

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

View File

@@ -16,6 +16,7 @@ import androidx.compose.material.icons.filled.Add
import androidx.compose.material.icons.filled.Check import androidx.compose.material.icons.filled.Check
import androidx.compose.material.icons.filled.Delete import androidx.compose.material.icons.filled.Delete
import androidx.compose.material.icons.filled.Edit import androidx.compose.material.icons.filled.Edit
import androidx.compose.material.icons.filled.HealthAndSafety
import androidx.compose.material.icons.filled.KeyboardArrowDown import androidx.compose.material.icons.filled.KeyboardArrowDown
import androidx.compose.material.icons.filled.KeyboardArrowUp import androidx.compose.material.icons.filled.KeyboardArrowUp
import androidx.compose.material.icons.filled.Share import androidx.compose.material.icons.filled.Share
@@ -260,6 +261,32 @@ fun SessionDetailScreen(
} }
}, },
actions = { actions = {
if (session?.duration != null) {
val healthConnectManager = viewModel.getHealthConnectManager()
val isHealthConnectEnabled by
healthConnectManager.isEnabled.collectAsState(
initial = false
)
val hasPermissions by
healthConnectManager.hasPermissions.collectAsState(
initial = false
)
if (isHealthConnectEnabled && hasPermissions) {
IconButton(
onClick = {
viewModel.manualSyncToHealthConnect(sessionId)
}
) {
Icon(
imageVector = Icons.Default.HealthAndSafety,
contentDescription = "Sync to Health Connect",
tint = MaterialTheme.colorScheme.primary
)
}
}
}
// Share button // Share button
if (session?.duration != null) { // Only show for completed sessions if (session?.duration != null) { // Only show for completed sessions
IconButton( IconButton(
@@ -537,7 +564,7 @@ fun SessionDetailScreen(
viewModel.addAttempt(attempt) viewModel.addAttempt(attempt)
showAddAttemptDialog = false showAddAttemptDialog = false
}, },
onProblemCreated = { problem -> viewModel.addProblem(problem) } onProblemCreated = { problem -> viewModel.addProblem(problem, context) }
) )
} }

View File

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

View File

@@ -17,6 +17,7 @@ import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import com.atridad.openclimb.R import com.atridad.openclimb.R
import com.atridad.openclimb.ui.components.SyncIndicator import com.atridad.openclimb.ui.components.SyncIndicator
import com.atridad.openclimb.ui.health.HealthConnectCard
import com.atridad.openclimb.ui.viewmodel.ClimbViewModel import com.atridad.openclimb.ui.viewmodel.ClimbViewModel
import java.io.File import java.io.File
import java.time.Instant import java.time.Instant
@@ -37,12 +38,17 @@ fun SettingsScreen(viewModel: ClimbViewModel) {
val isTesting by syncService.isTesting.collectAsState() val isTesting by syncService.isTesting.collectAsState()
val lastSyncTime by syncService.lastSyncTime.collectAsState() val lastSyncTime by syncService.lastSyncTime.collectAsState()
val syncError by syncService.syncError.collectAsState() val syncError by syncService.syncError.collectAsState()
val isAutoSyncEnabled by syncService.isAutoSyncEnabled.collectAsState()
// State for dialogs // State for dialogs
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 showDeleteImagesDialog 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) }
var authToken by remember { mutableStateOf(syncService.authToken) } var authToken by remember { mutableStateOf(syncService.authToken) }
@@ -275,8 +281,10 @@ fun SettingsScreen(viewModel: ClimbViewModel) {
} }
Spacer(modifier = Modifier.width(16.dp)) Spacer(modifier = Modifier.width(16.dp))
Switch( Switch(
checked = syncService.isAutoSyncEnabled, checked = isAutoSyncEnabled,
onCheckedChange = { syncService.isAutoSyncEnabled = it } onCheckedChange = { enabled ->
syncService.setAutoSyncEnabled(enabled)
}
) )
} }
} }
@@ -375,7 +383,8 @@ fun SettingsScreen(viewModel: ClimbViewModel) {
} }
} }
// Data Management Section item { HealthConnectCard() }
item { item {
Card(modifier = Modifier.fillMaxWidth(), shape = RoundedCornerShape(16.dp)) { Card(modifier = Modifier.fillMaxWidth(), shape = RoundedCornerShape(16.dp)) {
Column(modifier = Modifier.fillMaxWidth().padding(16.dp)) { Column(modifier = Modifier.fillMaxWidth().padding(16.dp)) {
@@ -475,6 +484,48 @@ 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.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 +954,43 @@ 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") }
} }
) )
} }
// Delete All Images dialog
if (showDeleteImagesDialog) {
AlertDialog(
onDismissRequest = { showDeleteImagesDialog = false },
title = { Text("Delete All Images") },
text = {
Text(
"This will permanently delete ALL image files from your device.\n\nProblems will keep their references but the actual image files will be removed. This cannot be undone.\n\nConsider exporting your data first if you want to keep your images."
)
},
confirmButton = {
TextButton(
onClick = {
isDeletingImages = true
showDeleteImagesDialog = false
coroutineScope.launch {
viewModel.deleteAllImages(context)
isDeletingImages = false
viewModel.setMessage("All images deleted successfully!")
}
}
) { Text("Delete", color = MaterialTheme.colorScheme.error) }
},
dismissButton = {
TextButton(onClick = { showDeleteImagesDialog = false }) { Text("Cancel") }
}
)
}
} }

View File

@@ -3,10 +3,12 @@ package com.atridad.openclimb.ui.viewmodel
import android.content.Context import android.content.Context
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import com.atridad.openclimb.data.health.HealthConnectManager
import com.atridad.openclimb.data.model.* 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
@@ -16,8 +18,14 @@ import kotlinx.coroutines.flow.*
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
class ClimbViewModel(private val repository: ClimbRepository, val syncService: SyncService) : class ClimbViewModel(
ViewModel() { private val repository: ClimbRepository,
val syncService: SyncService,
private val context: Context
) : ViewModel() {
// Health Connect manager
private val healthConnectManager = HealthConnectManager(context)
// UI State flows // UI State flows
private val _uiState = MutableStateFlow(ClimbUiState()) private val _uiState = MutableStateFlow(ClimbUiState())
@@ -106,25 +114,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)
} }
} }
@@ -148,6 +172,41 @@ class ClimbViewModel(private val repository: ClimbRepository, val syncService: S
ImageUtils.cleanupOrphanedImages(context, referencedImagePaths) ImageUtils.cleanupOrphanedImages(context, referencedImagePaths)
} }
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)) }
fun getProblemsByGym(gymId: String): Flow<List<Problem>> = repository.getProblemsByGym(gymId) fun getProblemsByGym(gymId: String): Flow<List<Problem>> = repository.getProblemsByGym(gymId)
@@ -240,7 +299,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,7 +326,7 @@ class ClimbViewModel(private val repository: ClimbRepository, val syncService: S
ClimbStatsWidgetProvider.updateAllWidgets(context) ClimbStatsWidgetProvider.updateAllWidgets(context)
// Auto-sync now happens automatically via repository callback syncToHealthConnect(completedSession)
_uiState.value = _uiState.value.copy(message = "Session completed!") _uiState.value = _uiState.value.copy(message = "Session completed!")
} }
@@ -295,7 +353,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 +467,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 {
@@ -429,6 +490,90 @@ class ClimbViewModel(private val repository: ClimbRepository, val syncService: S
} }
} }
private fun syncToHealthConnect(session: ClimbSession) {
viewModelScope.launch {
try {
val gym = repository.getGymById(session.gymId)
val gymName = gym?.name ?: "Unknown Gym"
val attempts = repository.getAttemptsBySession(session.id).first()
val attemptCount = attempts.size
val result = healthConnectManager.autoSyncSession(session, gymName, attemptCount)
result
.onSuccess {
_uiState.value =
_uiState.value.copy(
message =
"Session synced to Health Connect successfully!"
)
}
.onFailure { error ->
if (healthConnectManager.isReadySync()) {
_uiState.value =
_uiState.value.copy(
error =
"Failed to sync to Health Connect: ${error.message}"
)
}
}
} catch (e: Exception) {
if (healthConnectManager.isReadySync()) {
_uiState.value =
_uiState.value.copy(error = "Health Connect sync error: ${e.message}")
}
}
}
}
fun manualSyncToHealthConnect(sessionId: String) {
viewModelScope.launch {
try {
val session = repository.getSessionById(sessionId)
if (session == null) {
_uiState.value = _uiState.value.copy(error = "Session not found")
return@launch
}
if (session.status != SessionStatus.COMPLETED) {
_uiState.value =
_uiState.value.copy(error = "Only completed sessions can be synced")
return@launch
}
val gym = repository.getGymById(session.gymId)
val gymName = gym?.name ?: "Unknown Gym"
val attempts = repository.getAttemptsBySession(session.id).first()
val attemptCount = attempts.size
val result =
healthConnectManager.syncClimbingSession(session, gymName, attemptCount)
result
.onSuccess {
_uiState.value =
_uiState.value.copy(
message =
"Session synced to Health Connect successfully!"
)
}
.onFailure { error ->
_uiState.value =
_uiState.value.copy(
error =
"Failed to sync to Health Connect: ${error.message}"
)
}
} catch (e: Exception) {
_uiState.value =
_uiState.value.copy(error = "Health Connect sync error: ${e.message}")
}
}
}
fun getHealthConnectManager(): HealthConnectManager = healthConnectManager
// Share operations // Share operations
suspend fun generateSessionShareCard(context: Context, sessionId: String): File? = suspend fun generateSessionShareCard(context: Context, sessionId: String): File? =
withContext(Dispatchers.IO) { withContext(Dispatchers.IO) {

View File

@@ -1,5 +1,6 @@
package com.atridad.openclimb.ui.viewmodel package com.atridad.openclimb.ui.viewmodel
import android.content.Context
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider import androidx.lifecycle.ViewModelProvider
import com.atridad.openclimb.data.repository.ClimbRepository import com.atridad.openclimb.data.repository.ClimbRepository
@@ -7,13 +8,14 @@ import com.atridad.openclimb.data.sync.SyncService
class ClimbViewModelFactory( class ClimbViewModelFactory(
private val repository: ClimbRepository, private val repository: ClimbRepository,
private val syncService: SyncService private val syncService: SyncService,
private val context: Context
) : ViewModelProvider.Factory { ) : ViewModelProvider.Factory {
@Suppress("UNCHECKED_CAST") @Suppress("UNCHECKED_CAST")
override fun <T : ViewModel> create(modelClass: Class<T>): T { override fun <T : ViewModel> create(modelClass: Class<T>): T {
if (modelClass.isAssignableFrom(ClimbViewModel::class.java)) { if (modelClass.isAssignableFrom(ClimbViewModel::class.java)) {
return ClimbViewModel(repository, syncService) as T return ClimbViewModel(repository, syncService, context) as T
} }
throw IllegalArgumentException("Unknown ViewModel class") throw IllegalArgumentException("Unknown ViewModel class")
} }

View File

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

View File

@@ -5,7 +5,10 @@ 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 androidx.exifinterface.media.ExifInterface
import java.io.File import java.io.File
import java.io.FileOutputStream import java.io.FileOutputStream
import java.util.UUID import java.util.UUID
@@ -17,7 +20,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()
@@ -25,7 +28,57 @@ object ImageUtils {
return imagesDir return imagesDir
} }
/** Saves an image from a URI with compression and proper orientation */ /** Saves an image from a URI while preserving EXIF orientation data */
private fun saveImageWithExif(
context: Context,
imageUri: Uri,
originalBitmap: Bitmap,
outputFile: File
): Boolean {
return try {
// Get EXIF data from original image
val originalExif =
context.contentResolver.openInputStream(imageUri)?.use { input ->
ExifInterface(input)
}
// Compress and save the bitmap
val compressedBitmap = compressImage(originalBitmap)
FileOutputStream(outputFile).use { output ->
compressedBitmap.compress(Bitmap.CompressFormat.JPEG, IMAGE_QUALITY, output)
}
// Copy EXIF data to the saved file
originalExif?.let { sourceExif ->
val destExif = ExifInterface(outputFile.absolutePath)
// Copy orientation and other important EXIF attributes
val orientationValue = sourceExif.getAttribute(ExifInterface.TAG_ORIENTATION)
orientationValue?.let { destExif.setAttribute(ExifInterface.TAG_ORIENTATION, it) }
// Copy other useful EXIF data
sourceExif.getAttribute(ExifInterface.TAG_DATETIME)?.let {
destExif.setAttribute(ExifInterface.TAG_DATETIME, it)
}
sourceExif.getAttribute(ExifInterface.TAG_GPS_LATITUDE)?.let {
destExif.setAttribute(ExifInterface.TAG_GPS_LATITUDE, it)
}
sourceExif.getAttribute(ExifInterface.TAG_GPS_LONGITUDE)?.let {
destExif.setAttribute(ExifInterface.TAG_GPS_LONGITUDE, it)
}
destExif.saveAttributes()
}
compressedBitmap.recycle()
true
} catch (e: Exception) {
e.printStackTrace()
false
}
}
/** Saves an image from a URI with compression */
fun saveImageFromUri( fun saveImageFromUri(
context: Context, context: Context,
imageUri: Uri, imageUri: Uri,
@@ -40,26 +93,18 @@ object ImageUtils {
} }
?: return null ?: return null
val orientedBitmap = correctImageOrientation(context, imageUri, originalBitmap) // Always require deterministic naming
val compressedBitmap = compressImage(orientedBitmap) require(problemId != null && imageIndex != null) {
"Problem ID and image index are required for deterministic image naming"
}
val filename = val filename = ImageNamingUtils.generateImageFilename(problemId, imageIndex)
if (problemId != null && imageIndex != null) {
ImageNamingUtils.generateImageFilename(problemId, imageIndex)
} else {
"${UUID.randomUUID()}.jpg"
}
val imageFile = File(getImagesDirectory(context), filename) val imageFile = File(getImagesDirectory(context), filename)
FileOutputStream(imageFile).use { output -> val success = saveImageWithExif(context, imageUri, originalBitmap, imageFile)
compressedBitmap.compress(Bitmap.CompressFormat.JPEG, IMAGE_QUALITY, output)
}
originalBitmap.recycle() originalBitmap.recycle()
if (orientedBitmap != originalBitmap) {
orientedBitmap.recycle() if (!success) return null
}
compressedBitmap.recycle()
"$IMAGES_DIR/$filename" "$IMAGES_DIR/$filename"
} catch (e: Exception) { } catch (e: Exception) {
@@ -73,35 +118,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 +257,62 @@ 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 tempFilename = "temp_${UUID.randomUUID()}.jpg"
val imageFile = File(getImagesDirectory(context), tempFilename)
val success = saveImageWithExif(context, imageUri, originalBitmap, imageFile)
originalBitmap.recycle()
if (!success) return null
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 {
@@ -247,21 +348,40 @@ object ImageUtils {
filename: String filename: String
): String? { ): String? {
return try { return try {
val bitmap = BitmapFactory.decodeByteArray(imageData, 0, imageData.size) ?: return null
val compressedBitmap = compressImage(bitmap)
// Use the provided filename instead of generating a new UUID
val imageFile = File(getImagesDirectory(context), filename) val imageFile = File(getImagesDirectory(context), filename)
// Save compressed image // Check if image is too large and needs compression
FileOutputStream(imageFile).use { output -> if (imageData.size > 5 * 1024 * 1024) { // 5MB threshold
compressedBitmap.compress(Bitmap.CompressFormat.JPEG, IMAGE_QUALITY, output) // For large images, decode, compress, and try to preserve EXIF
} val bitmap =
BitmapFactory.decodeByteArray(imageData, 0, imageData.size) ?: return null
val compressedBitmap = compressImage(bitmap)
// Clean up bitmaps // Save compressed image
bitmap.recycle() FileOutputStream(imageFile).use { output ->
compressedBitmap.recycle() compressedBitmap.compress(Bitmap.CompressFormat.JPEG, IMAGE_QUALITY, output)
}
// Try to preserve EXIF orientation from original data
try {
val originalExif = ExifInterface(java.io.ByteArrayInputStream(imageData))
val destExif = ExifInterface(imageFile.absolutePath)
val orientationValue = originalExif.getAttribute(ExifInterface.TAG_ORIENTATION)
orientationValue?.let {
destExif.setAttribute(ExifInterface.TAG_ORIENTATION, it)
}
destExif.saveAttributes()
} catch (e: Exception) {
// If EXIF preservation fails, continue without it
Log.w("ImageUtils", "Failed to preserve EXIF data: ${e.message}")
}
bitmap.recycle()
compressedBitmap.recycle()
} else {
// For smaller images, save raw data to preserve all EXIF information
FileOutputStream(imageFile).use { output -> output.write(imageData) }
}
// Return relative path // Return relative path
"$IMAGES_DIR/$filename" "$IMAGES_DIR/$filename"

View File

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

View File

@@ -465,8 +465,9 @@
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 = 17; CURRENT_PROJECT_VERSION = 25;
DEVELOPMENT_TEAM = 4BC9Y2LL4B; DEVELOPMENT_TEAM = 4BC9Y2LL4B;
DRIVERKIT_DEPLOYMENT_TARGET = 24.6;
ENABLE_PREVIEWS = YES; ENABLE_PREVIEWS = YES;
GENERATE_INFOPLIST_FILE = YES; GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_FILE = OpenClimb/Info.plist; INFOPLIST_FILE = OpenClimb/Info.plist;
@@ -485,7 +486,8 @@
"$(inherited)", "$(inherited)",
"@executable_path/Frameworks", "@executable_path/Frameworks",
); );
MARKETING_VERSION = 1.3.0; MACOSX_DEPLOYMENT_TARGET = 15.6;
MARKETING_VERSION = 1.4.0;
PRODUCT_BUNDLE_IDENTIFIER = com.atridad.OpenClimb; PRODUCT_BUNDLE_IDENTIFIER = com.atridad.OpenClimb;
PRODUCT_NAME = "$(TARGET_NAME)"; PRODUCT_NAME = "$(TARGET_NAME)";
PROVISIONING_PROFILE_SPECIFIER = ""; PROVISIONING_PROFILE_SPECIFIER = "";
@@ -495,8 +497,11 @@
SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor; SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor;
SWIFT_EMIT_LOC_STRINGS = YES; SWIFT_EMIT_LOC_STRINGS = YES;
SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES; SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES;
SWIFT_VERSION = 5.0; SWIFT_VERSION = 6.0;
TARGETED_DEVICE_FAMILY = 1; TARGETED_DEVICE_FAMILY = 1;
TVOS_DEPLOYMENT_TARGET = 18.6;
WATCHOS_DEPLOYMENT_TARGET = 11.6;
XROS_DEPLOYMENT_TARGET = 2.6;
}; };
name = Debug; name = Debug;
}; };
@@ -508,8 +513,9 @@
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 = 17; CURRENT_PROJECT_VERSION = 25;
DEVELOPMENT_TEAM = 4BC9Y2LL4B; DEVELOPMENT_TEAM = 4BC9Y2LL4B;
DRIVERKIT_DEPLOYMENT_TARGET = 24.6;
ENABLE_PREVIEWS = YES; ENABLE_PREVIEWS = YES;
GENERATE_INFOPLIST_FILE = YES; GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_FILE = OpenClimb/Info.plist; INFOPLIST_FILE = OpenClimb/Info.plist;
@@ -528,7 +534,8 @@
"$(inherited)", "$(inherited)",
"@executable_path/Frameworks", "@executable_path/Frameworks",
); );
MARKETING_VERSION = 1.3.0; MACOSX_DEPLOYMENT_TARGET = 15.6;
MARKETING_VERSION = 1.4.0;
PRODUCT_BUNDLE_IDENTIFIER = com.atridad.OpenClimb; PRODUCT_BUNDLE_IDENTIFIER = com.atridad.OpenClimb;
PRODUCT_NAME = "$(TARGET_NAME)"; PRODUCT_NAME = "$(TARGET_NAME)";
PROVISIONING_PROFILE_SPECIFIER = ""; PROVISIONING_PROFILE_SPECIFIER = "";
@@ -538,8 +545,11 @@
SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor; SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor;
SWIFT_EMIT_LOC_STRINGS = YES; SWIFT_EMIT_LOC_STRINGS = YES;
SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES; SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES;
SWIFT_VERSION = 5.0; SWIFT_VERSION = 6.0;
TARGETED_DEVICE_FAMILY = 1; TARGETED_DEVICE_FAMILY = 1;
TVOS_DEPLOYMENT_TARGET = 18.6;
WATCHOS_DEPLOYMENT_TARGET = 11.6;
XROS_DEPLOYMENT_TARGET = 2.6;
}; };
name = Release; name = Release;
}; };
@@ -592,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 = 17; CURRENT_PROJECT_VERSION = 25;
DEVELOPMENT_TEAM = 4BC9Y2LL4B; DEVELOPMENT_TEAM = 4BC9Y2LL4B;
GENERATE_INFOPLIST_FILE = YES; GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_FILE = SessionStatusLive/Info.plist; INFOPLIST_FILE = SessionStatusLive/Info.plist;
@@ -603,7 +613,7 @@
"@executable_path/Frameworks", "@executable_path/Frameworks",
"@executable_path/../../Frameworks", "@executable_path/../../Frameworks",
); );
MARKETING_VERSION = 1.3.0; MARKETING_VERSION = 1.4.0;
PRODUCT_BUNDLE_IDENTIFIER = com.atridad.OpenClimb.SessionStatusLive; PRODUCT_BUNDLE_IDENTIFIER = com.atridad.OpenClimb.SessionStatusLive;
PRODUCT_NAME = "$(TARGET_NAME)"; PRODUCT_NAME = "$(TARGET_NAME)";
SKIP_INSTALL = YES; SKIP_INSTALL = YES;
@@ -622,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 = 17; CURRENT_PROJECT_VERSION = 25;
DEVELOPMENT_TEAM = 4BC9Y2LL4B; DEVELOPMENT_TEAM = 4BC9Y2LL4B;
GENERATE_INFOPLIST_FILE = YES; GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_FILE = SessionStatusLive/Info.plist; INFOPLIST_FILE = SessionStatusLive/Info.plist;
@@ -633,7 +643,7 @@
"@executable_path/Frameworks", "@executable_path/Frameworks",
"@executable_path/../../Frameworks", "@executable_path/../../Frameworks",
); );
MARKETING_VERSION = 1.3.0; MARKETING_VERSION = 1.4.0;
PRODUCT_BUNDLE_IDENTIFIER = com.atridad.OpenClimb.SessionStatusLive; PRODUCT_BUNDLE_IDENTIFIER = com.atridad.OpenClimb.SessionStatusLive;
PRODUCT_NAME = "$(TARGET_NAME)"; PRODUCT_NAME = "$(TARGET_NAME)";
SKIP_INSTALL = YES; SKIP_INSTALL = YES;

Binary file not shown.

Before

Width:  |  Height:  |  Size: 22 KiB

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 36 KiB

After

Width:  |  Height:  |  Size: 35 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 12 KiB

After

Width:  |  Height:  |  Size: 8.4 KiB

View File

@@ -1,18 +1,22 @@
<?xml version="1.0" encoding="UTF-8"?> <?xml version="1.0" encoding="UTF-8"?>
<svg width="1024" height="1024" viewBox="0 0 1024 1024" xmlns="http://www.w3.org/2000/svg"> <svg width="1024" height="1024" viewBox="0 0 1024 1024" xmlns="http://schemas.android.com/2000/svg">
<!-- Dark background with rounded corners for iOS -->
<rect width="1024" height="1024" fill="#1A1A1A" rx="180" ry="180"/> <rect width="1024" height="1024" fill="#1A1A1A" rx="180" ry="180"/>
<g transform="translate(512, 512) scale(2.5)"> <!-- Transform to match Android layout exactly -->
<polygon points="-70,80 -20,-60 30,80" <g transform="translate(512, 512) scale(4.75) translate(-54, -42.5)">
<!-- Left mountain (yellow/amber) - matches Android coordinates with white border -->
<polygon points="15,70 35,25 55,70"
fill="#FFC107" fill="#FFC107"
stroke="#1C1C1C" stroke="#FFFFFF"
stroke-width="4" stroke-width="3"
stroke-linejoin="round"/> stroke-linejoin="round"/>
<polygon points="0,80 50,-80 100,80" <!-- Right mountain (red) - matches Android coordinates with white border -->
<polygon points="40,70 65,15 90,70"
fill="#F44336" fill="#F44336"
stroke="#1C1C1C" stroke="#FFFFFF"
stroke-width="4" stroke-width="3"
stroke-linejoin="round"/> stroke-linejoin="round"/>
</g> </g>
</svg> </svg>

Before

Width:  |  Height:  |  Size: 604 B

After

Width:  |  Height:  |  Size: 913 B

View File

@@ -1,18 +1,22 @@
<?xml version="1.0" encoding="UTF-8"?> <?xml version="1.0" encoding="UTF-8"?>
<svg width="1024" height="1024" viewBox="0 0 1024 1024" xmlns="http://www.w3.org/2000/svg"> <svg width="1024" height="1024" viewBox="0 0 1024 1024" xmlns="http://schemas.android.com/2000/svg">
<!-- White background with rounded corners for iOS -->
<rect width="1024" height="1024" fill="#FFFFFF" rx="180" ry="180"/> <rect width="1024" height="1024" fill="#FFFFFF" rx="180" ry="180"/>
<g transform="translate(512, 512) scale(2.5)"> <!-- Transform to match Android layout exactly -->
<polygon points="-70,80 -20,-60 30,80" <g transform="translate(512, 512) scale(4.75) translate(-54, -42.5)">
<!-- Left mountain (yellow/amber) - matches Android coordinates -->
<polygon points="15,70 35,25 55,70"
fill="#FFC107" fill="#FFC107"
stroke="#1C1C1C" stroke="#1C1C1C"
stroke-width="4" stroke-width="3"
stroke-linejoin="round"/> stroke-linejoin="round"/>
<polygon points="0,80 50,-80 100,80" <!-- Right mountain (red) - matches Android coordinates -->
<polygon points="40,70 65,15 90,70"
fill="#F44336" fill="#F44336"
stroke="#1C1C1C" stroke="#1C1C1C"
stroke-width="4" stroke-width="3"
stroke-linejoin="round"/> stroke-linejoin="round"/>
</g> </g>
</svg> </svg>

Before

Width:  |  Height:  |  Size: 604 B

After

Width:  |  Height:  |  Size: 878 B

View File

@@ -1,19 +1,23 @@
<?xml version="1.0" encoding="UTF-8"?> <?xml version="1.0" encoding="UTF-8"?>
<svg width="1024" height="1024" viewBox="0 0 1024 1024" xmlns="http://www.w3.org/2000/svg"> <svg width="1024" height="1024" viewBox="0 0 1024 1024" xmlns="http://schemas.android.com/2000/svg">
<!-- Transparent background with rounded corners for iOS tinted mode -->
<rect width="1024" height="1024" fill="transparent" rx="180" ry="180"/> <rect width="1024" height="1024" fill="transparent" rx="180" ry="180"/>
<g transform="translate(512, 512) scale(2.5)"> <!-- Transform to match Android layout exactly -->
<polygon points="-70,80 -20,-60 30,80" <g transform="translate(512, 512) scale(4.75) translate(-54, -42.5)">
<!-- Left mountain - matches Android coordinates, black fill for tinting -->
<polygon points="15,70 35,25 55,70"
fill="#000000" fill="#000000"
stroke="#000000" stroke="#000000"
stroke-width="4" stroke-width="3"
stroke-linejoin="round" stroke-linejoin="round"
opacity="0.8"/> opacity="0.8"/>
<polygon points="0,80 50,-80 100,80" <!-- Right mountain - matches Android coordinates, black fill for tinting -->
<polygon points="40,70 65,15 90,70"
fill="#000000" fill="#000000"
stroke="#000000" stroke="#000000"
stroke-width="4" stroke-width="3"
stroke-linejoin="round" stroke-linejoin="round"
opacity="0.9"/> opacity="0.9"/>
</g> </g>

Before

Width:  |  Height:  |  Size: 662 B

After

Width:  |  Height:  |  Size: 981 B

View File

@@ -0,0 +1,56 @@
{
"images": [
{
"filename": "app_logo_256.png",
"idiom": "universal",
"scale": "1x"
},
{
"appearances": [
{
"appearance": "luminosity",
"value": "dark"
}
],
"filename": "app_logo_256_dark.png",
"idiom": "universal",
"scale": "1x"
},
{
"filename": "app_logo_256.png",
"idiom": "universal",
"scale": "2x"
},
{
"appearances": [
{
"appearance": "luminosity",
"value": "dark"
}
],
"filename": "app_logo_256_dark.png",
"idiom": "universal",
"scale": "2x"
},
{
"filename": "app_logo_256.png",
"idiom": "universal",
"scale": "3x"
},
{
"appearances": [
{
"appearance": "luminosity",
"value": "dark"
}
],
"filename": "app_logo_256_dark.png",
"idiom": "universal",
"scale": "3x"
}
],
"info": {
"author": "xcode",
"version": 1
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.1 KiB

View File

@@ -1,56 +0,0 @@
{
"images" : [
{
"filename" : "mountains_icon_256.png",
"idiom" : "universal",
"scale" : "1x"
},
{
"appearances" : [
{
"appearance" : "luminosity",
"value" : "dark"
}
],
"filename" : "mountains_icon_256_dark.png",
"idiom" : "universal",
"scale" : "1x"
},
{
"filename" : "mountains_icon_256.png",
"idiom" : "universal",
"scale" : "2x"
},
{
"appearances" : [
{
"appearance" : "luminosity",
"value" : "dark"
}
],
"filename" : "mountains_icon_256_dark.png",
"idiom" : "universal",
"scale" : "2x"
},
{
"filename" : "mountains_icon_256.png",
"idiom" : "universal",
"scale" : "3x"
},
{
"appearances" : [
{
"appearance" : "luminosity",
"value" : "dark"
}
],
"filename" : "mountains_icon_256_dark.png",
"idiom" : "universal",
"scale" : "3x"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.7 KiB

View File

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

View File

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

View File

@@ -50,6 +50,8 @@ struct ContentView: View {
Task { Task {
try? await Task.sleep(nanoseconds: 200_000_000) // 0.2 seconds try? await Task.sleep(nanoseconds: 200_000_000) // 0.2 seconds
dataManager.onAppBecomeActive() dataManager.onAppBecomeActive()
// Re-verify health integration when app becomes active
await dataManager.healthKitService.verifyAndRestoreIntegration()
} }
} else if newPhase == .background { } else if newPhase == .background {
dataManager.onAppEnterBackground() dataManager.onAppEnterBackground()
@@ -59,6 +61,10 @@ struct ContentView: View {
setupNotificationObservers() setupNotificationObservers()
// Trigger auto-sync on app start only // Trigger auto-sync on app start only
dataManager.syncService.triggerAutoSync(dataManager: dataManager) dataManager.syncService.triggerAutoSync(dataManager: dataManager)
// Verify and restore health integration if it was previously enabled
Task {
await dataManager.healthKitService.verifyAndRestoreIntegration()
}
} }
.onDisappear { .onDisappear {
removeNotificationObservers() removeNotificationObservers()
@@ -90,6 +96,8 @@ struct ContentView: View {
// Small delay to ensure app is fully active // Small delay to ensure app is fully active
try? await Task.sleep(nanoseconds: 800_000_000) // 0.8 seconds try? await Task.sleep(nanoseconds: 800_000_000) // 0.8 seconds
await dataManager.onAppBecomeActive() await dataManager.onAppBecomeActive()
// Re-verify health integration when returning from background
await dataManager.healthKitService.verifyAndRestoreIntegration()
} }
} }
@@ -103,6 +111,8 @@ struct ContentView: View {
Task { Task {
try? await Task.sleep(nanoseconds: 300_000_000) // 0.3 seconds try? await Task.sleep(nanoseconds: 300_000_000) // 0.3 seconds
await dataManager.onAppBecomeActive() await dataManager.onAppBecomeActive()
// Ensure health integration is verified
await dataManager.healthKitService.verifyAndRestoreIntegration()
} }
} }

View File

@@ -10,5 +10,9 @@
<string>This app needs access to your photo library to add photos to climbing problems.</string> <string>This app needs access to your photo library to add photos to climbing problems.</string>
<key>NSCameraUsageDescription</key> <key>NSCameraUsageDescription</key>
<string>This app needs access to your camera to take photos of climbing problems.</string> <string>This app needs access to your camera to take photos of climbing problems.</string>
<key>NSHealthShareUsageDescription</key>
<string>This app needs access to save your climbing workouts to Apple Health.</string>
<key>NSHealthUpdateUsageDescription</key>
<string>This app needs access to save your climbing workouts to Apple Health.</string>
</dict> </dict>
</plist> </plist>

View File

@@ -1,8 +1,8 @@
import ActivityKit import ActivityKit
import Foundation import Foundation
struct SessionActivityAttributes: ActivityAttributes { struct SessionActivityAttributes: ActivityAttributes, Sendable {
public struct ContentState: Codable, Hashable { public struct ContentState: Codable, Hashable, Sendable {
var elapsed: TimeInterval var elapsed: TimeInterval
var totalAttempts: Int var totalAttempts: Int
var completedProblems: Int var completedProblems: Int
@@ -17,4 +17,3 @@ extension SessionActivityAttributes {
SessionActivityAttributes(gymName: "Summit Climbing", startTime: Date()) SessionActivityAttributes(gymName: "Summit Climbing", startTime: Date())
} }
} }

View File

@@ -6,5 +6,9 @@
<array> <array>
<string>group.com.atridad.OpenClimb</string> <string>group.com.atridad.OpenClimb</string>
</array> </array>
<key>com.apple.developer.healthkit</key>
<true/>
<key>com.apple.developer.healthkit.access</key>
<array/>
</dict> </dict>
</plist> </plist>

View File

@@ -0,0 +1,236 @@
import Combine
import Foundation
import HealthKit
@MainActor
class HealthKitService: ObservableObject {
static let shared = HealthKitService()
private let healthStore = HKHealthStore()
private var currentWorkoutStartDate: Date?
private var currentWorkoutSessionId: UUID?
@Published var isAuthorized = false
@Published var isEnabled = false
private let userDefaults = UserDefaults.standard
private let isEnabledKey = "healthKitEnabled"
private let workoutStartDateKey = "healthKitWorkoutStartDate"
private let workoutSessionIdKey = "healthKitWorkoutSessionId"
private init() {
loadSettings()
restoreActiveWorkout()
}
func loadSettings() {
isEnabled = userDefaults.bool(forKey: isEnabledKey)
if HKHealthStore.isHealthDataAvailable() {
checkAuthorization()
}
}
/// Restore active workout state
private func restoreActiveWorkout() {
if let startDate = userDefaults.object(forKey: workoutStartDateKey) as? Date,
let sessionIdString = userDefaults.string(forKey: workoutSessionIdKey),
let sessionId = UUID(uuidString: sessionIdString)
{
currentWorkoutStartDate = startDate
currentWorkoutSessionId = sessionId
print("HealthKit: Restored active workout from \(startDate)")
}
}
/// Persist active workout state
private func persistActiveWorkout() {
if let startDate = currentWorkoutStartDate, let sessionId = currentWorkoutSessionId {
userDefaults.set(startDate, forKey: workoutStartDateKey)
userDefaults.set(sessionId.uuidString, forKey: workoutSessionIdKey)
} else {
userDefaults.removeObject(forKey: workoutStartDateKey)
userDefaults.removeObject(forKey: workoutSessionIdKey)
}
}
/// Verify and restore health integration
func verifyAndRestoreIntegration() async {
guard isEnabled else { return }
guard HKHealthStore.isHealthDataAvailable() else {
print("HealthKit: Device does not support HealthKit")
return
}
checkAuthorization()
if !isAuthorized {
print(
"HealthKit: Integration was enabled but authorization lost, attempting to restore..."
)
do {
try await requestAuthorization()
print("HealthKit: Authorization restored successfully")
} catch {
print("HealthKit: Failed to restore authorization: \(error.localizedDescription)")
}
} else {
print("HealthKit: Integration verified - authorization is valid")
}
if hasActiveWorkout() {
print(
"HealthKit: Active workout restored - started at \(currentWorkoutStartDate!)"
)
}
}
func setEnabled(_ enabled: Bool) {
isEnabled = enabled
userDefaults.set(enabled, forKey: isEnabledKey)
}
func requestAuthorization() async throws {
guard HKHealthStore.isHealthDataAvailable() else {
throw HealthKitError.notAvailable
}
let workoutType = HKObjectType.workoutType()
let energyBurnedType = HKObjectType.quantityType(forIdentifier: .activeEnergyBurned)!
let typesToShare: Set<HKSampleType> = [
workoutType,
energyBurnedType,
]
let typesToRead: Set<HKObjectType> = [
workoutType
]
try await healthStore.requestAuthorization(toShare: typesToShare, read: typesToRead)
self.isAuthorized = true
}
private func checkAuthorization() {
let workoutType = HKObjectType.workoutType()
let status = healthStore.authorizationStatus(for: workoutType)
isAuthorized = (status == .sharingAuthorized)
}
func startWorkout(startDate: Date, sessionId: UUID) async throws {
guard isEnabled && isAuthorized else {
return
}
guard HKHealthStore.isHealthDataAvailable() else {
throw HealthKitError.notAvailable
}
currentWorkoutStartDate = startDate
currentWorkoutSessionId = sessionId
persistActiveWorkout()
print("HealthKit: Started workout for session \(sessionId)")
}
func endWorkout(endDate: Date) async throws {
guard isEnabled && isAuthorized else {
return
}
guard let startDate = currentWorkoutStartDate else {
return
}
guard HKHealthStore.isHealthDataAvailable() else {
throw HealthKitError.notAvailable
}
let duration = endDate.timeIntervalSince(startDate)
let calories = estimateCalories(durationInMinutes: duration / 60.0)
let energyBurned = HKQuantity(unit: .kilocalorie(), doubleValue: calories)
let workoutConfiguration = HKWorkoutConfiguration()
workoutConfiguration.activityType = .climbing
workoutConfiguration.locationType = .indoor
let builder = HKWorkoutBuilder(
healthStore: healthStore,
configuration: workoutConfiguration,
device: .local()
)
do {
try await builder.beginCollection(at: startDate)
let energyBurnedType = HKQuantityType.quantityType(forIdentifier: .activeEnergyBurned)!
let energySample = HKQuantitySample(
type: energyBurnedType,
quantity: energyBurned,
start: startDate,
end: endDate
)
try await builder.addSamples([energySample])
try await builder.addMetadata([HKMetadataKeyIndoorWorkout: true])
try await builder.endCollection(at: endDate)
let workout = try await builder.finishWorkout()
print(
"HealthKit: Workout saved successfully with id: \(workout?.uuid.uuidString ?? "unknown")"
)
currentWorkoutStartDate = nil
currentWorkoutSessionId = nil
persistActiveWorkout()
} catch {
print("HealthKit: Failed to save workout: \(error.localizedDescription)")
currentWorkoutStartDate = nil
currentWorkoutSessionId = nil
persistActiveWorkout()
throw HealthKitError.workoutSaveFailed
}
}
func cancelWorkout() {
currentWorkoutStartDate = nil
currentWorkoutSessionId = nil
persistActiveWorkout()
print("HealthKit: Workout cancelled")
}
func hasActiveWorkout() -> Bool {
return currentWorkoutStartDate != nil
}
private func estimateCalories(durationInMinutes: Double) -> Double {
let caloriesPerMinute = 8.0
return durationInMinutes * caloriesPerMinute
}
}
enum HealthKitError: LocalizedError {
case notAvailable
case notAuthorized
case workoutStartFailed
case workoutSaveFailed
var errorDescription: String? {
switch self {
case .notAvailable:
return "HealthKit is not available on this device"
case .notAuthorized:
return "HealthKit authorization not granted"
case .workoutStartFailed:
return "Failed to start HealthKit workout"
case .workoutSaveFailed:
return "Failed to save workout to HealthKit"
}
}
}

View File

@@ -9,15 +9,20 @@ class SyncService: ObservableObject {
@Published var syncError: String? @Published var syncError: String?
@Published var isConnected = false @Published var isConnected = false
@Published var isTesting = false @Published var isTesting = false
@Published var isOfflineMode = false
private let userDefaults = UserDefaults.standard private let userDefaults = UserDefaults.standard
private var syncTask: Task<Void, Never>?
private var 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"
static let authToken = "sync_auth_token" static let authToken = "sync_auth_token"
static let lastSyncTime = "last_sync_time" static let lastSyncTime = "last_sync_time"
static let isConnected = "sync_is_connected" static let isConnected = "is_connected"
static let autoSyncEnabled = "auto_sync_enabled" static let autoSyncEnabled = "auto_sync_enabled"
static let offlineMode = "offline_mode"
} }
var serverURL: String { var serverURL: String {
@@ -43,7 +48,9 @@ class SyncService: ObservableObject {
if let lastSync = userDefaults.object(forKey: Keys.lastSyncTime) as? Date { if let lastSync = userDefaults.object(forKey: Keys.lastSyncTime) as? Date {
self.lastSyncTime = lastSync self.lastSyncTime = lastSync
} }
self.isConnected = userDefaults.bool(forKey: Keys.isConnected) isConnected = userDefaults.bool(forKey: Keys.isConnected)
isAutoSyncEnabled = userDefaults.object(forKey: Keys.autoSyncEnabled) as? Bool ?? true
isOfflineMode = userDefaults.bool(forKey: Keys.offlineMode)
} }
func downloadData() async throws -> ClimbDataBackup { func downloadData() async throws -> ClimbDataBackup {
@@ -144,6 +151,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 +183,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 {
@@ -197,6 +210,11 @@ class SyncService: ObservableObject {
} }
func syncWithServer(dataManager: ClimbingDataManager) async throws { func syncWithServer(dataManager: ClimbingDataManager) async throws {
if isOfflineMode {
print("Sync skipped: Offline mode is enabled.")
return
}
guard isConfigured else { guard isConfigured else {
throw SyncError.notConfigured throw SyncError.notConfigured
} }
@@ -283,7 +301,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 +310,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 = problemId: problem.id, imageIndex: index)
ImageNamingUtils.isValidImageFilename(serverFilename)
? serverFilename
: ImageNamingUtils.generateImageFilename(
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,12 +340,8 @@ 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 = problemId: problem.id.uuidString, imageIndex: index)
ImageNamingUtils.isValidImageFilename(filename)
? filename
: ImageNamingUtils.generateImageFilename(
problemId: problem.id.uuidString, imageIndex: index)
// Load image data // Load image data
let imageManager = ImageManager.shared let imageManager = ImageManager.shared
@@ -392,6 +399,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 +674,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)
if let imageData = imageManager.loadImageData(fromPath: fullPath) { {
imageFiles.append((filename: filename, data: imageData)) // Create a mapping from normalized paths to actual paths
for problem in problems {
for (index, imagePath) in problem.imagePaths.enumerated() {
// Get the actual filename on disk
let actualFilename = URL(fileURLWithPath: imagePath).lastPathComponent
let fullPath = imageManager.imagesDirectory.appendingPathComponent(
actualFilename
).path
// Generate the normalized filename for the ZIP
let normalizedFilename = ImageNamingUtils.generateImageFilename(
problemId: problem.id.uuidString, imageIndex: index)
if let imageData = imageManager.loadImageData(fromPath: fullPath) {
imageFiles.append((filename: normalizedFilename, data: imageData))
}
}
} }
} }
@@ -875,20 +943,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 +1000,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,8 +1020,16 @@ 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: - Merging
// MARK: - Safe Merge Functions // MARK: - Safe Merge Functions
private func mergeGyms(local: [Gym], server: [BackupGym], deletedItems: [DeletedItem]) -> [Gym] private func mergeGyms(local: [Gym], server: [BackupGym], deletedItems: [DeletedItem]) -> [Gym]
@@ -926,13 +1037,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,41 +1065,44 @@ 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 {
var problemToAdd = serverProblem let localHasProblem = localProblemIds.contains(serverProblem.id)
let isDeleted = deletedProblemIds.contains(serverProblem.id)
// Update image paths if needed if !localHasProblem && !isDeleted {
if !imagePathMapping.isEmpty { var problemToAdd = serverProblem
let updatedImagePaths = serverProblem.imagePaths?.compactMap { oldPath in
imagePathMapping[oldPath] ?? oldPath if !imagePathMapping.isEmpty, let imagePaths = serverProblem.imagePaths,
!imagePaths.isEmpty
{
let updatedImagePaths = imagePaths.compactMap { oldPath in
imagePathMapping[oldPath] ?? oldPath
}
if updatedImagePaths != imagePaths {
problemToAdd = BackupProblem(
id: serverProblem.id,
gymId: serverProblem.gymId,
name: serverProblem.name,
description: serverProblem.description,
climbType: serverProblem.climbType,
difficulty: serverProblem.difficulty,
tags: serverProblem.tags,
location: serverProblem.location,
imagePaths: updatedImagePaths,
isActive: serverProblem.isActive,
dateSet: serverProblem.dateSet,
notes: serverProblem.notes,
createdAt: serverProblem.createdAt,
updatedAt: serverProblem.updatedAt
)
}
} }
problemToAdd = BackupProblem(
id: serverProblem.id,
gymId: serverProblem.gymId,
name: serverProblem.name,
description: serverProblem.description,
climbType: serverProblem.climbType,
difficulty: serverProblem.difficulty,
tags: serverProblem.tags,
location: serverProblem.location,
imagePaths: updatedImagePaths,
isActive: serverProblem.isActive,
dateSet: serverProblem.dateSet,
notes: serverProblem.notes,
createdAt: serverProblem.createdAt,
updatedAt: serverProblem.updatedAt
)
}
if let serverProblemConverted = try? problemToAdd.toProblem() { 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 +1119,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 +1145,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 +1164,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)
} }
} }

View File

@@ -106,7 +106,7 @@ import SwiftUI
.font(.subheadline) .font(.subheadline)
.fontWeight(.medium) .fontWeight(.medium)
Image("MountainsIcon") Image("AppLogo")
.resizable() .resizable()
.frame(width: 24, height: 24) .frame(width: 24, height: 24)
.background(Circle().fill(.quaternary)) .background(Circle().fill(.quaternary))
@@ -115,7 +115,7 @@ import SwiftUI
.font(.caption) .font(.caption)
.foregroundColor(.secondary) .foregroundColor(.secondary)
Image("MountainsIcon") Image("AppLogo")
.resizable() .resizable()
.frame(width: 32, height: 32) .frame(width: 32, height: 32)
.background(Circle().fill(.quaternary)) .background(Circle().fill(.quaternary))
@@ -322,7 +322,7 @@ import SwiftUI
// Check if main bundle contains the expected icon assets // Check if main bundle contains the expected icon assets
let expectedAssets = [ let expectedAssets = [
"AppIcon", "AppIcon",
"MountainsIcon", "AppLogo",
] ]
for asset in expectedAssets { for asset in expectedAssets {
@@ -376,7 +376,7 @@ import SwiftUI
.font(.headline) .font(.headline)
HStack(spacing: 20) { HStack(spacing: 20) {
Image("MountainsIcon") Image("AppLogo")
.resizable() .resizable()
.frame(width: 64, height: 64) .frame(width: 64, height: 64)
.background( .background(
@@ -385,7 +385,7 @@ import SwiftUI
) )
VStack(alignment: .leading) { VStack(alignment: .leading) {
Text("MountainsIcon") Text("AppLogo")
.font(.subheadline) .font(.subheadline)
.fontWeight(.medium) .fontWeight(.medium)
Text("In-app icon display") Text("In-app icon display")

View File

@@ -1,9 +1,12 @@
import Foundation import Foundation
import ImageIO
import SwiftUI import SwiftUI
import UIKit
class ImageManager { class ImageManager {
static let shared = ImageManager() static let shared = ImageManager()
private let thumbnailCache = NSCache<NSString, UIImage>()
private let fileManager = FileManager.default private let fileManager = FileManager.default
private let appSupportDirectoryName = "OpenClimb" private let appSupportDirectoryName = "OpenClimb"
private let imagesDirectoryName = "Images" private let imagesDirectoryName = "Images"
@@ -478,6 +481,51 @@ class ImageManager {
return nil return nil
} }
func loadThumbnail(fromPath path: String, targetSize: CGSize) async -> UIImage? {
let cacheKey = "\(path)-\(targetSize.width)x\(targetSize.height)" as NSString
if let cachedImage = thumbnailCache.object(forKey: cacheKey) {
return cachedImage
}
guard let imageData = loadImageData(fromPath: path) else {
return nil
}
let options: [CFString: Any] = [
kCGImageSourceCreateThumbnailFromImageIfAbsent: true,
kCGImageSourceCreateThumbnailWithTransform: true,
kCGImageSourceShouldCacheImmediately: true,
kCGImageSourceThumbnailMaxPixelSize: max(targetSize.width, targetSize.height)
* UIScreen.main.scale,
]
guard let imageSource = CGImageSourceCreateWithData(imageData as CFData, nil) else {
return UIImage(data: imageData)
}
let properties = CGImageSourceCopyPropertiesAtIndex(imageSource, 0, nil) as? [CFString: Any]
let orientation = properties?[kCGImagePropertyOrientation] as? UInt32 ?? 1
if let cgImage = CGImageSourceCreateThumbnailAtIndex(
imageSource, 0, options as CFDictionary)
{
let imageOrientation = UIImage.Orientation(rawValue: Int(orientation - 1)) ?? .up
let thumbnail = UIImage(
cgImage: cgImage, scale: UIScreen.main.scale, orientation: imageOrientation)
thumbnailCache.setObject(thumbnail, forKey: cacheKey)
return thumbnail
} else {
if let fallbackImage = UIImage(data: imageData) {
thumbnailCache.setObject(fallbackImage, forKey: cacheKey)
return fallbackImage
}
}
return nil
}
func imageExists(atPath path: String) -> Bool { func imageExists(atPath path: String) -> Bool {
let primaryPath = getFullPath(from: path) let primaryPath = getFullPath(from: path)
let backupPath = backupDirectory.appendingPathComponent(getRelativePath(from: path)) let backupPath = backupDirectory.appendingPathComponent(getRelativePath(from: path))
@@ -852,4 +900,5 @@ class ImageManager {
print("ERROR: Failed to migrate from previous Application Support: \(error)") print("ERROR: Failed to migrate from previous Application Support: \(error)")
} }
} }
} }

View File

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

View File

@@ -0,0 +1,147 @@
import SwiftUI
import UIKit
struct OrientationAwareImage: View {
let imagePath: String
let contentMode: ContentMode
@State private var uiImage: UIImage?
@State private var isLoading = true
@State private var hasFailed = false
init(imagePath: String, contentMode: ContentMode = .fit) {
self.imagePath = imagePath
self.contentMode = contentMode
}
var body: some View {
Group {
if let uiImage = uiImage {
Image(uiImage: uiImage)
.resizable()
.aspectRatio(contentMode: contentMode)
} else if hasFailed {
Image(systemName: "photo")
.foregroundColor(.gray)
} else {
ProgressView()
.progressViewStyle(CircularProgressViewStyle())
}
}
.onAppear {
loadImageWithCorrectOrientation()
}
.onChange(of: imagePath) { _ in
loadImageWithCorrectOrientation()
}
}
private func loadImageWithCorrectOrientation() {
Task.detached(priority: .userInitiated) {
let correctedImage = await loadAndCorrectImage()
await MainActor.run {
self.uiImage = correctedImage
self.isLoading = false
self.hasFailed = correctedImage == nil
}
}
}
private func loadAndCorrectImage() async -> UIImage? {
guard let data = ImageManager.shared.loadImageData(fromPath: imagePath) else { return nil }
guard let originalImage = UIImage(data: data) else { return nil }
return correctImageOrientation(originalImage)
}
/// Corrects the orientation of a UIImage based on its EXIF data
private func correctImageOrientation(_ image: UIImage) -> UIImage {
// If the image is already in the correct orientation, return as-is
if image.imageOrientation == .up {
return image
}
// Calculate the proper transformation matrix
var transform = CGAffineTransform.identity
switch image.imageOrientation {
case .down, .downMirrored:
transform = transform.translatedBy(x: image.size.width, y: image.size.height)
transform = transform.rotated(by: .pi)
case .left, .leftMirrored:
transform = transform.translatedBy(x: image.size.width, y: 0)
transform = transform.rotated(by: .pi / 2)
case .right, .rightMirrored:
transform = transform.translatedBy(x: 0, y: image.size.height)
transform = transform.rotated(by: -.pi / 2)
case .up, .upMirrored:
break
@unknown default:
break
}
switch image.imageOrientation {
case .upMirrored, .downMirrored:
transform = transform.translatedBy(x: image.size.width, y: 0)
transform = transform.scaledBy(x: -1, y: 1)
case .leftMirrored, .rightMirrored:
transform = transform.translatedBy(x: image.size.height, y: 0)
transform = transform.scaledBy(x: -1, y: 1)
case .up, .down, .left, .right:
break
@unknown default:
break
}
// Create a new image context and apply the transformation
guard let cgImage = image.cgImage else { return image }
let context = CGContext(
data: nil,
width: Int(image.size.width),
height: Int(image.size.height),
bitsPerComponent: cgImage.bitsPerComponent,
bytesPerRow: 0,
space: cgImage.colorSpace ?? CGColorSpaceCreateDeviceRGB(),
bitmapInfo: cgImage.bitmapInfo.rawValue
)
guard let ctx = context else { return image }
ctx.concatenate(transform)
switch image.imageOrientation {
case .left, .leftMirrored, .right, .rightMirrored:
ctx.draw(
cgImage, in: CGRect(x: 0, y: 0, width: image.size.height, height: image.size.width))
default:
ctx.draw(
cgImage, in: CGRect(x: 0, y: 0, width: image.size.width, height: image.size.height))
}
guard let newCGImage = ctx.makeImage() else { return image }
return UIImage(cgImage: newCGImage)
}
}
// MARK: - Convenience Extensions
extension OrientationAwareImage {
/// Creates an orientation-aware image with fill content mode
static func fill(imagePath: String) -> OrientationAwareImage {
OrientationAwareImage(imagePath: imagePath, contentMode: .fill)
}
/// Creates an orientation-aware image with fit content mode
static func fit(imagePath: String) -> OrientationAwareImage {
OrientationAwareImage(imagePath: imagePath, contentMode: .fit)
}
}

View File

@@ -1,5 +1,6 @@
import Combine import Combine
import Foundation import Foundation
import HealthKit
import SwiftUI import SwiftUI
import UniformTypeIdentifiers import UniformTypeIdentifiers
@@ -27,12 +28,12 @@ class ClimbingDataManager: ObservableObject {
private let sharedUserDefaults = UserDefaults(suiteName: "group.com.atridad.OpenClimb") private let sharedUserDefaults = UserDefaults(suiteName: "group.com.atridad.OpenClimb")
private let encoder = JSONEncoder() private let encoder = JSONEncoder()
private let decoder = JSONDecoder() private let decoder = JSONDecoder()
private var liveActivityObserver: NSObjectProtocol? nonisolated(unsafe) private var liveActivityObserver: NSObjectProtocol?
nonisolated(unsafe) private var migrationObserver: NSObjectProtocol?
// Sync service for automatic syncing
let syncService = SyncService() let syncService = SyncService()
let healthKitService = HealthKitService.shared
// Published property to propagate sync state changes
@Published var isSyncing = false @Published var isSyncing = false
private enum Keys { private enum Keys {
@@ -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() {
@@ -96,6 +100,9 @@ class ClimbingDataManager: ObservableObject {
loadSessions() loadSessions()
loadAttempts() loadAttempts()
loadActiveSession() loadActiveSession()
// Clean up orphaned data after loading
cleanupOrphanedData()
} }
private func loadGyms() { private func loadGyms() {
@@ -286,7 +293,16 @@ class ClimbingDataManager: ObservableObject {
} }
func deleteProblem(_ problem: Problem) { func deleteProblem(_ problem: Problem) {
// Delete associated attempts first // Track deletion of the problem
trackDeletion(itemId: problem.id.uuidString, itemType: "problem")
// Find and track all attempts for this problem as deleted
let problemAttempts = attempts.filter { $0.problemId == problem.id }
for attempt in problemAttempts {
trackDeletion(itemId: attempt.id.uuidString, itemType: "attempt")
}
// Delete associated attempts
attempts.removeAll { $0.problemId == problem.id } attempts.removeAll { $0.problemId == problem.id }
saveAttempts() saveAttempts()
@@ -295,7 +311,6 @@ class ClimbingDataManager: ObservableObject {
// Delete the problem // Delete the problem
problems.removeAll { $0.id == problem.id } problems.removeAll { $0.id == problem.id }
trackDeletion(itemId: problem.id.uuidString, itemType: "problem")
saveProblems() saveProblems()
DataStateManager.shared.updateDataState() DataStateManager.shared.updateDataState()
@@ -336,6 +351,18 @@ class ClimbingDataManager: ObservableObject {
for: newSession, gymName: gym.name) for: newSession, gymName: gym.name)
} }
} }
if healthKitService.isEnabled {
Task {
do {
try await healthKitService.startWorkout(
startDate: newSession.startTime ?? Date(),
sessionId: newSession.id)
} catch {
print("Failed to start HealthKit workout: \(error.localizedDescription)")
}
}
}
} }
func endSession(_ sessionId: UUID) { func endSession(_ sessionId: UUID) {
@@ -361,6 +388,17 @@ class ClimbingDataManager: ObservableObject {
Task { Task {
await LiveActivityManager.shared.endLiveActivity() await LiveActivityManager.shared.endLiveActivity()
} }
if healthKitService.isEnabled {
Task {
do {
try await healthKitService.endWorkout(
endDate: completedSession.endTime ?? Date())
} catch {
print("Failed to end HealthKit workout: \(error.localizedDescription)")
}
}
}
} }
} }
@@ -387,7 +425,16 @@ class ClimbingDataManager: ObservableObject {
} }
func deleteSession(_ session: ClimbSession) { func deleteSession(_ session: ClimbSession) {
// Delete associated attempts first // Track deletion of the session
trackDeletion(itemId: session.id.uuidString, itemType: "session")
// Find and track all attempts for this session as deleted
let sessionAttempts = attempts.filter { $0.sessionId == session.id }
for attempt in sessionAttempts {
trackDeletion(itemId: attempt.id.uuidString, itemType: "attempt")
}
// Delete associated attempts
attempts.removeAll { $0.sessionId == session.id } attempts.removeAll { $0.sessionId == session.id }
saveAttempts() saveAttempts()
@@ -399,7 +446,6 @@ class ClimbingDataManager: ObservableObject {
// Delete the session // Delete the session
sessions.removeAll { $0.id == session.id } sessions.removeAll { $0.id == session.id }
trackDeletion(itemId: session.id.uuidString, itemType: "session")
saveSessions() saveSessions()
DataStateManager.shared.updateDataState() DataStateManager.shared.updateDataState()
@@ -525,6 +571,162 @@ class ClimbingDataManager: ObservableObject {
return gym(withId: mostUsedGymId) return gym(withId: mostUsedGymId)
} }
/// Clean up orphaned data - removes attempts that reference non-existent sessions
/// and removes duplicate attempts. This ensures data integrity and prevents
/// orphaned attempts from appearing in widgets
private func cleanupOrphanedData() {
let validSessionIds = Set(sessions.map { $0.id })
let validProblemIds = Set(problems.map { $0.id })
let validGymIds = Set(gyms.map { $0.id })
let initialAttemptCount = attempts.count
// Remove attempts that reference deleted sessions or problems
let orphanedAttempts = attempts.filter { attempt in
!validSessionIds.contains(attempt.sessionId)
|| !validProblemIds.contains(attempt.problemId)
}
if !orphanedAttempts.isEmpty {
print("🧹 Cleaning up \(orphanedAttempts.count) orphaned attempts")
// Track these as deleted to prevent sync from re-introducing them
for attempt in orphanedAttempts {
trackDeletion(itemId: attempt.id.uuidString, itemType: "attempt")
}
// Remove orphaned attempts
attempts.removeAll { attempt in
!validSessionIds.contains(attempt.sessionId)
|| !validProblemIds.contains(attempt.problemId)
}
}
// Remove duplicate attempts (same session, problem, and timestamp within 1 second)
var seenAttempts: Set<String> = []
var duplicateIds: [UUID] = []
for attempt in attempts.sorted(by: { $0.timestamp < $1.timestamp }) {
// Create a unique key based on session, problem, and rounded timestamp
let timestampKey = Int(attempt.timestamp.timeIntervalSince1970)
let key =
"\(attempt.sessionId.uuidString)_\(attempt.problemId.uuidString)_\(timestampKey)"
if seenAttempts.contains(key) {
duplicateIds.append(attempt.id)
print("🧹 Found duplicate attempt: \(attempt.id)")
} else {
seenAttempts.insert(key)
}
}
if !duplicateIds.isEmpty {
print("🧹 Removing \(duplicateIds.count) duplicate attempts")
// Track duplicates as deleted
for attemptId in duplicateIds {
trackDeletion(itemId: attemptId.uuidString, itemType: "attempt")
}
// Remove duplicates
attempts.removeAll { duplicateIds.contains($0.id) }
}
if initialAttemptCount != attempts.count {
saveAttempts()
let removedCount = initialAttemptCount - attempts.count
print(
"Cleanup complete. Removed \(removedCount) attempts. Remaining: \(attempts.count)"
)
}
// Remove problems that reference deleted gyms
let orphanedProblems = problems.filter { problem in
!validGymIds.contains(problem.gymId)
}
if !orphanedProblems.isEmpty {
print("🧹 Cleaning up \(orphanedProblems.count) orphaned problems")
for problem in orphanedProblems {
trackDeletion(itemId: problem.id.uuidString, itemType: "problem")
}
problems.removeAll { problem in
!validGymIds.contains(problem.gymId)
}
saveProblems()
}
// Remove sessions that reference deleted gyms
let orphanedSessions = sessions.filter { session in
!validGymIds.contains(session.gymId)
}
if !orphanedSessions.isEmpty {
print("🧹 Cleaning up \(orphanedSessions.count) orphaned sessions")
for session in orphanedSessions {
trackDeletion(itemId: session.id.uuidString, itemType: "session")
}
sessions.removeAll { session in
!validGymIds.contains(session.gymId)
}
saveSessions()
}
}
/// Validate data integrity and return a report
/// This can be called manually to check for issues
func validateDataIntegrity() -> String {
let validSessionIds = Set(sessions.map { $0.id })
let validProblemIds = Set(problems.map { $0.id })
let validGymIds = Set(gyms.map { $0.id })
let orphanedAttempts = attempts.filter { attempt in
!validSessionIds.contains(attempt.sessionId)
|| !validProblemIds.contains(attempt.problemId)
}
let orphanedProblems = problems.filter { problem in
!validGymIds.contains(problem.gymId)
}
let orphanedSessions = sessions.filter { session in
!validGymIds.contains(session.gymId)
}
var report = "Data Integrity Report:\n"
report += "---------------------\n"
report += "Gyms: \(gyms.count)\n"
report += "Problems: \(problems.count)\n"
report += "Sessions: \(sessions.count)\n"
report += "Attempts: \(attempts.count)\n"
report += "\nOrphaned Data:\n"
report += "Orphaned Attempts: \(orphanedAttempts.count)\n"
report += "Orphaned Problems: \(orphanedProblems.count)\n"
report += "Orphaned Sessions: \(orphanedSessions.count)\n"
if orphanedAttempts.isEmpty && orphanedProblems.isEmpty && orphanedSessions.isEmpty {
report += "\nNo integrity issues found"
} else {
report += "\nIssues found - run cleanup to fix"
}
return report
}
/// Manually trigger cleanup of orphaned data
/// This can be called from settings or debug menu
func manualDataCleanup() {
cleanupOrphanedData()
successMessage = "Data cleanup completed"
clearMessageAfterDelay()
}
func resetAllData(showSuccessMessage: Bool = true) { func resetAllData(showSuccessMessage: Bool = true) {
gyms.removeAll() gyms.removeAll()
problems.removeAll() problems.removeAll()
@@ -551,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",
@@ -561,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")
@@ -680,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)
} }
} }
@@ -706,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)
} }
} }
@@ -936,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,

View File

@@ -10,14 +10,10 @@ final class LiveActivityManager {
static let shared = LiveActivityManager() static let shared = LiveActivityManager()
private init() {} private init() {}
private var currentActivity: Activity<SessionActivityAttributes>? nonisolated(unsafe) private var currentActivity: Activity<SessionActivityAttributes>?
private var healthCheckTimer: Timer? private var healthCheckTimer: Timer?
private var lastHealthCheck: Date = Date() private var lastHealthCheck: Date = Date()
deinit {
healthCheckTimer?.invalidate()
}
/// Check if there's an active session and restart Live Activity if needed /// Check if there's an active session and restart Live Activity if needed
func restartLiveActivityIfNeeded(activeSession: ClimbSession?, gymName: String?) async { func restartLiveActivityIfNeeded(activeSession: ClimbSession?, gymName: String?) async {
// If we have an active session but no Live Activity, restart it // If we have an active session but no Live Activity, restart it
@@ -130,7 +126,8 @@ final class LiveActivityManager {
completedProblems: completedProblems completedProblems: completedProblems
) )
await currentActivity.update(.init(state: updatedContentState, staleDate: nil)) nonisolated(unsafe) let activity = currentActivity
await activity.update(.init(state: updatedContentState, staleDate: nil))
} }
/// Call this when a ClimbSession ends to end the Live Activity /// Call this when a ClimbSession ends to end the Live Activity
@@ -141,7 +138,8 @@ final class LiveActivityManager {
// First end the tracked activity if it exists // First end the tracked activity if it exists
if let currentActivity { if let currentActivity {
print("Ending tracked Live Activity: \(currentActivity.id)") print("Ending tracked Live Activity: \(currentActivity.id)")
await currentActivity.end(nil, dismissalPolicy: .immediate) nonisolated(unsafe) let activity = currentActivity
await activity.end(nil, dismissalPolicy: .immediate)
self.currentActivity = nil self.currentActivity = nil
print("Tracked Live Activity ended successfully") print("Tracked Live Activity ended successfully")
} }
@@ -200,7 +198,7 @@ final class LiveActivityManager {
print("🩺 Starting Live Activity health checks") print("🩺 Starting Live Activity health checks")
healthCheckTimer = Timer.scheduledTimer(withTimeInterval: 30.0, repeats: true) { healthCheckTimer = Timer.scheduledTimer(withTimeInterval: 30.0, repeats: true) {
[weak self] _ in [weak self] _ in
Task { @MainActor in Task { @MainActor [weak self] in
await self?.performHealthCheck() await self?.performHealthCheck()
} }
} }
@@ -257,7 +255,7 @@ final class LiveActivityManager {
{ {
guard currentActivity != nil else { return } guard currentActivity != nil else { return }
Task { Task { @MainActor in
while currentActivity != nil { while currentActivity != nil {
let elapsed = Date().timeIntervalSince(session.startTime ?? session.date) let elapsed = Date().timeIntervalSince(session.startTime ?? session.date)
await updateLiveActivity( await updateLiveActivity(

View File

@@ -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,
@@ -1284,129 +1308,19 @@ struct EditAttemptView: View {
struct ProblemSelectionImageView: View { struct ProblemSelectionImageView: View {
let imagePath: String let imagePath: String
@State private var uiImage: UIImage?
@State private var isLoading = true
@State private var hasFailed = false
var body: some View { var body: some View {
Group { OrientationAwareImage.fill(imagePath: imagePath)
if let uiImage = uiImage { .frame(height: 80)
Image(uiImage: uiImage) .clipped()
.resizable() .cornerRadius(8)
.aspectRatio(contentMode: .fill)
.frame(height: 80)
.clipped()
.cornerRadius(8)
} else if hasFailed {
RoundedRectangle(cornerRadius: 8)
.fill(.gray.opacity(0.2))
.frame(height: 80)
.overlay {
Image(systemName: "photo")
.foregroundColor(.gray)
.font(.title3)
}
} else {
RoundedRectangle(cornerRadius: 8)
.fill(.gray.opacity(0.3))
.frame(height: 80)
.overlay {
ProgressView()
.scaleEffect(0.8)
}
}
}
.onAppear {
loadImage()
}
}
private func loadImage() {
guard !imagePath.isEmpty else {
hasFailed = true
isLoading = false
return
}
DispatchQueue.global(qos: .userInitiated).async {
if let data = ImageManager.shared.loadImageData(fromPath: imagePath),
let image = UIImage(data: data)
{
DispatchQueue.main.async {
self.uiImage = image
self.isLoading = false
}
} else {
DispatchQueue.main.async {
self.hasFailed = true
self.isLoading = false
}
}
}
} }
} }
struct ProblemSelectionImageFullView: View { struct ProblemSelectionImageFullView: View {
let imagePath: String let imagePath: String
@State private var uiImage: UIImage?
@State private var isLoading = true
@State private var hasFailed = false
var body: some View { var body: some View {
Group { OrientationAwareImage.fit(imagePath: imagePath)
if let uiImage = uiImage {
Image(uiImage: uiImage)
.resizable()
.aspectRatio(contentMode: .fit)
} else if hasFailed {
RoundedRectangle(cornerRadius: 12)
.fill(.gray.opacity(0.2))
.frame(height: 250)
.overlay {
VStack(spacing: 8) {
Image(systemName: "photo")
.foregroundColor(.gray)
.font(.largeTitle)
Text("Image not available")
.foregroundColor(.gray)
}
}
} else {
RoundedRectangle(cornerRadius: 12)
.fill(.gray.opacity(0.3))
.frame(height: 250)
.overlay {
ProgressView()
}
}
}
.onAppear {
loadImage()
}
}
private func loadImage() {
guard !imagePath.isEmpty else {
hasFailed = true
isLoading = false
return
}
DispatchQueue.global(qos: .userInitiated).async {
if let data = ImageManager.shared.loadImageData(fromPath: imagePath),
let image = UIImage(data: data)
{
DispatchQueue.main.async {
self.uiImage = image
self.isLoading = false
}
} else {
DispatchQueue.main.async {
self.hasFailed = true
self.isLoading = false
}
}
}
} }
} }

View File

@@ -556,15 +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
var allImagePaths = imagePaths
for data in imageData {
if let relativePath = ImageManager.shared.saveImageData(data) {
allImagePaths.append(relativePath)
}
}
if isEditing, let problem = existingProblem { if isEditing, let problem = existingProblem {
var allImagePaths = imagePaths
let newImagesStartIndex = imagePaths.count
if imageData.count > newImagesStartIndex {
for i in newImagesStartIndex..<imageData.count {
let data = imageData[i]
let imageIndex = allImagePaths.count
let deterministicName = ImageNamingUtils.generateImageFilename(
problemId: problem.id.uuidString, imageIndex: imageIndex)
if let relativePath = ImageManager.shared.saveImageData(
data, withName: deterministicName)
{
allImagePaths.append(relativePath)
}
}
}
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,
@@ -589,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()

View File

@@ -443,128 +443,20 @@ struct ImageViewerView: View {
struct ProblemDetailImageView: View { struct ProblemDetailImageView: View {
let imagePath: String let imagePath: String
@State private var uiImage: UIImage?
@State private var isLoading = true
@State private var hasFailed = false
var body: some View { var body: some View {
Group { OrientationAwareImage.fill(imagePath: imagePath)
if let uiImage = uiImage { .frame(width: 120, height: 120)
Image(uiImage: uiImage) .clipped()
.resizable() .cornerRadius(12)
.aspectRatio(contentMode: .fill)
.frame(width: 120, height: 120)
.clipped()
.cornerRadius(12)
} else if hasFailed {
RoundedRectangle(cornerRadius: 12)
.fill(.gray.opacity(0.2))
.frame(width: 120, height: 120)
.overlay {
Image(systemName: "photo")
.foregroundColor(.gray)
.font(.title2)
}
} else {
RoundedRectangle(cornerRadius: 12)
.fill(.gray.opacity(0.3))
.frame(width: 120, height: 120)
.overlay {
ProgressView()
}
}
}
.onAppear {
loadImage()
}
}
private func loadImage() {
guard !imagePath.isEmpty else {
hasFailed = true
isLoading = false
return
}
DispatchQueue.global(qos: .userInitiated).async {
if let data = ImageManager.shared.loadImageData(fromPath: imagePath),
let image = UIImage(data: data)
{
DispatchQueue.main.async {
self.uiImage = image
self.isLoading = false
}
} else {
DispatchQueue.main.async {
self.hasFailed = true
self.isLoading = false
}
}
}
} }
} }
struct ProblemDetailImageFullView: View { struct ProblemDetailImageFullView: View {
let imagePath: String let imagePath: String
@State private var uiImage: UIImage?
@State private var isLoading = true
@State private var hasFailed = false
var body: some View { var body: some View {
Group { OrientationAwareImage.fit(imagePath: imagePath)
if let uiImage = uiImage {
Image(uiImage: uiImage)
.resizable()
.aspectRatio(contentMode: .fit)
} else if hasFailed {
Rectangle()
.fill(.gray.opacity(0.2))
.frame(height: 250)
.overlay {
VStack(spacing: 8) {
Image(systemName: "photo")
.foregroundColor(.gray)
.font(.largeTitle)
Text("Image not available")
.foregroundColor(.gray)
}
}
} else {
Rectangle()
.fill(.gray.opacity(0.3))
.frame(height: 250)
.overlay {
ProgressView()
}
}
}
.onAppear {
loadImage()
}
}
private func loadImage() {
guard !imagePath.isEmpty else {
hasFailed = true
isLoading = false
return
}
DispatchQueue.global(qos: .userInitiated).async {
if let data = ImageManager.shared.loadImageData(fromPath: imagePath),
let image = UIImage(data: data)
{
DispatchQueue.main.async {
self.uiImage = image
self.isLoading = false
}
} else {
DispatchQueue.main.async {
self.hasFailed = true
self.isLoading = false
}
}
}
} }
} }

View File

@@ -9,8 +9,26 @@ struct ProblemsView: View {
@State private var showingSearch = false @State private var showingSearch = false
@FocusState private var isSearchFocused: Bool @FocusState private var isSearchFocused: Bool
private var filteredProblems: [Problem] { @State private var cachedFilteredProblems: [Problem] = []
var filtered = dataManager.problems
private func updateFilteredProblems() {
Task(priority: .userInitiated) {
let result = await computeFilteredProblems()
// Switch back to the main thread to update the UI
await MainActor.run {
cachedFilteredProblems = result
}
}
}
private func computeFilteredProblems() async -> [Problem] {
// Capture dependencies for safe background processing
let problems = dataManager.problems
let searchText = self.searchText
let selectedClimbType = self.selectedClimbType
let selectedGym = self.selectedGym
var filtered = problems
// Apply search filter // Apply search filter
if !searchText.isEmpty { if !searchText.isEmpty {
@@ -32,9 +50,19 @@ struct ProblemsView: View {
filtered = filtered.filter { $0.gymId == gym.id } filtered = filtered.filter { $0.gymId == gym.id }
} }
// Separate active and inactive problems // Separate active and inactive problems with stable sorting
let active = filtered.filter { $0.isActive }.sorted { $0.updatedAt > $1.updatedAt } let active = filtered.filter { $0.isActive }.sorted {
let inactive = filtered.filter { !$0.isActive }.sorted { $0.updatedAt > $1.updatedAt } if $0.updatedAt == $1.updatedAt {
return $0.id.uuidString < $1.id.uuidString // Stable fallback
}
return $0.updatedAt > $1.updatedAt
}
let inactive = filtered.filter { !$0.isActive }.sorted {
if $0.updatedAt == $1.updatedAt {
return $0.id.uuidString < $1.id.uuidString // Stable fallback
}
return $0.updatedAt > $1.updatedAt
}
return active + inactive return active + inactive
} }
@@ -83,19 +111,19 @@ struct ProblemsView: View {
FilterSection( FilterSection(
selectedClimbType: $selectedClimbType, selectedClimbType: $selectedClimbType,
selectedGym: $selectedGym, selectedGym: $selectedGym,
filteredProblems: filteredProblems filteredProblems: cachedFilteredProblems
) )
.padding() .padding()
.background(.regularMaterial) .background(.regularMaterial)
} }
if filteredProblems.isEmpty { if cachedFilteredProblems.isEmpty {
EmptyProblemsView( EmptyProblemsView(
isEmpty: dataManager.problems.isEmpty, isEmpty: dataManager.problems.isEmpty,
isFiltered: !dataManager.problems.isEmpty isFiltered: !dataManager.problems.isEmpty
) )
} else { } else {
ProblemsList(problems: filteredProblems) ProblemsList(problems: cachedFilteredProblems)
} }
} }
} }
@@ -148,6 +176,21 @@ struct ProblemsView: View {
AddEditProblemView() AddEditProblemView()
} }
} }
.onAppear {
updateFilteredProblems()
}
.onChange(of: dataManager.problems) {
updateFilteredProblems()
}
.onChange(of: searchText) {
updateFilteredProblems()
}
.onChange(of: selectedClimbType) {
updateFilteredProblems()
}
.onChange(of: selectedGym) {
updateFilteredProblems()
}
} }
} }
@@ -259,9 +302,10 @@ struct ProblemsList: View {
@EnvironmentObject var dataManager: ClimbingDataManager @EnvironmentObject var dataManager: ClimbingDataManager
@State private var problemToDelete: Problem? @State private var problemToDelete: Problem?
@State private var problemToEdit: Problem? @State private var problemToEdit: Problem?
@State private var animationKey = 0
var body: some View { var body: some View {
List(problems) { problem in List(problems, id: \.id) { problem in
NavigationLink(destination: ProblemDetailView(problemId: problem.id)) { NavigationLink(destination: ProblemDetailView(problemId: problem.id)) {
ProblemRow(problem: problem) ProblemRow(problem: problem)
} }
@@ -273,8 +317,12 @@ struct ProblemsList: View {
} }
Button { Button {
let updatedProblem = problem.updated(isActive: !problem.isActive) // Use a spring animation for more natural movement
dataManager.updateProblem(updatedProblem) withAnimation(.spring(response: 0.5, dampingFraction: 0.8, blendDuration: 0.1))
{
let updatedProblem = problem.updated(isActive: !problem.isActive)
dataManager.updateProblem(updatedProblem)
}
} label: { } label: {
Label( Label(
problem.isActive ? "Mark as Reset" : "Mark as Active", problem.isActive ? "Mark as Reset" : "Mark as Active",
@@ -293,6 +341,17 @@ struct ProblemsList: View {
.tint(.blue) .tint(.blue)
} }
} }
.animation(
.spring(response: 0.5, dampingFraction: 0.8, blendDuration: 0.1),
value: animationKey
)
.onChange(of: problems) {
animationKey += 1
}
.listStyle(.plain)
.scrollContentBackground(.hidden)
.scrollIndicators(.hidden)
.clipped()
.alert("Delete Problem", isPresented: .constant(problemToDelete != nil)) { .alert("Delete Problem", isPresented: .constant(problemToDelete != nil)) {
Button("Cancel", role: .cancel) { Button("Cancel", role: .cancel) {
problemToDelete = nil problemToDelete = nil
@@ -322,6 +381,12 @@ struct ProblemRow: View {
dataManager.gym(withId: problem.gymId) dataManager.gym(withId: problem.gymId)
} }
private var isCompleted: Bool {
dataManager.attempts.contains { attempt in
attempt.problemId == problem.id && attempt.result.isSuccessful
}
}
var body: some View { var body: some View {
VStack(alignment: .leading, spacing: 8) { VStack(alignment: .leading, spacing: 8) {
HStack { HStack {
@@ -339,10 +404,24 @@ struct ProblemRow: View {
Spacer() Spacer()
VStack(alignment: .trailing, spacing: 4) { VStack(alignment: .trailing, spacing: 4) {
Text(problem.difficulty.grade) HStack(spacing: 8) {
.font(.title2) if !problem.imagePaths.isEmpty {
.fontWeight(.bold) Image(systemName: "photo")
.foregroundColor(.blue) .font(.system(size: 14, weight: .medium))
.foregroundColor(.blue)
}
if isCompleted {
Image(systemName: "checkmark.circle.fill")
.font(.system(size: 14, weight: .medium))
.foregroundColor(.green)
}
Text(problem.difficulty.grade)
.font(.title2)
.fontWeight(.bold)
.foregroundColor(.blue)
}
Text(problem.climbType.displayName) Text(problem.climbType.displayName)
.font(.caption) .font(.caption)
@@ -374,17 +453,6 @@ struct ProblemRow: View {
} }
} }
if !problem.imagePaths.isEmpty {
ScrollView(.horizontal, showsIndicators: false) {
LazyHStack(spacing: 8) {
ForEach(problem.imagePaths.prefix(3), id: \.self) { imagePath in
ProblemImageView(imagePath: imagePath)
}
}
.padding(.horizontal, 4)
}
}
if !problem.isActive { if !problem.isActive {
Text("Reset / No Longer Set") Text("Reset / No Longer Set")
.font(.caption) .font(.caption)
@@ -456,89 +524,6 @@ struct EmptyProblemsView: View {
} }
} }
struct ProblemImageView: View {
let imagePath: String
@State private var uiImage: UIImage?
@State private var isLoading = true
@State private var hasFailed = false
private static var imageCache: NSCache<NSString, UIImage> = {
let cache = NSCache<NSString, UIImage>()
cache.countLimit = 100
cache.totalCostLimit = 50 * 1024 * 1024 // 50MB
return cache
}()
var body: some View {
Group {
if let uiImage = uiImage {
Image(uiImage: uiImage)
.resizable()
.aspectRatio(contentMode: .fill)
.frame(width: 60, height: 60)
.clipped()
.cornerRadius(8)
} else if hasFailed {
RoundedRectangle(cornerRadius: 8)
.fill(.gray.opacity(0.2))
.frame(width: 60, height: 60)
.overlay {
Image(systemName: "photo")
.foregroundColor(.gray)
.font(.title3)
}
} else {
RoundedRectangle(cornerRadius: 8)
.fill(.gray.opacity(0.3))
.frame(width: 60, height: 60)
.overlay {
ProgressView()
.scaleEffect(0.8)
}
}
}
.onAppear {
loadImage()
}
}
private func loadImage() {
guard !imagePath.isEmpty else {
hasFailed = true
isLoading = false
return
}
let cacheKey = NSString(string: imagePath)
// Check cache first
if let cachedImage = Self.imageCache.object(forKey: cacheKey) {
self.uiImage = cachedImage
self.isLoading = false
return
}
DispatchQueue.global(qos: .userInitiated).async {
if let data = ImageManager.shared.loadImageData(fromPath: imagePath),
let image = UIImage(data: data)
{
// Cache the image
Self.imageCache.setObject(image, forKey: cacheKey)
DispatchQueue.main.async {
self.uiImage = image
self.isLoading = false
}
} else {
DispatchQueue.main.async {
self.hasFailed = true
self.isLoading = false
}
}
}
}
}
#Preview { #Preview {
ProblemsView() ProblemsView()
.environmentObject(ClimbingDataManager.preview) .environmentObject(ClimbingDataManager.preview)

View File

@@ -1,3 +1,4 @@
import HealthKit
import SwiftUI import SwiftUI
import UniformTypeIdentifiers import UniformTypeIdentifiers
@@ -16,6 +17,9 @@ struct SettingsView: View {
SyncSection() SyncSection()
.environmentObject(dataManager.syncService) .environmentObject(dataManager.syncService)
HealthKitSection()
.environmentObject(dataManager.healthKitService)
DataManagementSection( DataManagementSection(
activeSheet: $activeSheet activeSheet: $activeSheet
) )
@@ -77,6 +81,9 @@ struct DataManagementSection: View {
@State private var showingResetAlert = false @State private var showingResetAlert = false
@State private var isExporting = false @State private var isExporting = 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") {
// Export Data // Export Data
@@ -113,6 +120,27 @@ struct DataManagementSection: View {
} }
.foregroundColor(.primary) .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
@@ -136,6 +164,17 @@ 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("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() {
@@ -148,6 +187,64 @@ struct DataManagementSection: View {
} }
} }
} }
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 {
@@ -162,7 +259,7 @@ struct AppInfoSection: View {
var body: some View { var body: some View {
Section("App Information") { Section("App Information") {
HStack { HStack {
Image("MountainsIcon") Image("AppLogo")
.resizable() .resizable()
.frame(width: 24, height: 24) .frame(width: 24, height: 24)
VStack(alignment: .leading) { VStack(alignment: .leading) {
@@ -236,7 +333,7 @@ struct ExportDataView: View {
item: fileURL, item: fileURL,
preview: SharePreview( preview: SharePreview(
"OpenClimb Data Export", "OpenClimb Data Export",
image: Image("MountainsIcon")) image: Image("AppLogo"))
) { ) {
Label("Share Data", systemImage: "square.and.arrow.up") Label("Share Data", systemImage: "square.and.arrow.up")
.font(.headline) .font(.headline)
@@ -815,6 +912,86 @@ struct ImportDataView: View {
} }
} }
struct HealthKitSection: View {
@EnvironmentObject var healthKitService: HealthKitService
@State private var showingAuthorizationError = false
@State private var isRequestingAuthorization = false
var body: some View {
Section {
if !HKHealthStore.isHealthDataAvailable() {
HStack {
Image(systemName: "heart.slash")
.foregroundColor(.secondary)
Text("Apple Health not available")
.foregroundColor(.secondary)
}
} else {
Toggle(
isOn: Binding(
get: { healthKitService.isEnabled },
set: { newValue in
if newValue && !healthKitService.isAuthorized {
isRequestingAuthorization = true
Task {
do {
try await healthKitService.requestAuthorization()
await MainActor.run {
healthKitService.setEnabled(true)
isRequestingAuthorization = false
}
} catch {
await MainActor.run {
showingAuthorizationError = true
isRequestingAuthorization = false
}
}
}
} else if newValue {
healthKitService.setEnabled(true)
} else {
healthKitService.setEnabled(false)
}
}
)
) {
HStack {
Image(systemName: "heart.fill")
.foregroundColor(.red)
Text("Apple Health Integration")
}
}
.disabled(isRequestingAuthorization)
if healthKitService.isEnabled {
VStack(alignment: .leading, spacing: 4) {
Text(
"Climbing sessions will be recorded as workouts in Apple Health"
)
.font(.caption)
.foregroundColor(.secondary)
}
}
}
} header: {
Text("Health")
} footer: {
if healthKitService.isEnabled {
Text(
"Each climbing session will automatically be saved to Apple Health as a \"Climbing\" workout with the session duration."
)
}
}
.alert("Authorization Required", isPresented: $showingAuthorizationError) {
Button("OK", role: .cancel) {}
} message: {
Text(
"Please grant access to Apple Health in Settings to enable this feature."
)
}
}
}
#Preview { #Preview {
SettingsView() SettingsView()
.environmentObject(ClimbingDataManager.preview) .environmentObject(ClimbingDataManager.preview)

View File

@@ -326,4 +326,203 @@ final class OpenClimbTests: XCTestCase {
XCTAssertTrue(hasActiveSession, "Combined sessions should contain active session") XCTAssertTrue(hasActiveSession, "Combined sessions should contain active session")
XCTAssertTrue(hasCompletedSession, "Combined sessions should contain completed session") XCTAssertTrue(hasCompletedSession, "Combined sessions should contain completed session")
} }
// MARK: - Orphaned Data Cleanup Tests
func testOrphanedAttemptDetection() throws {
// Test that we can detect orphaned attempts (attempts referencing non-existent sessions)
let validSessionId = UUID()
let deletedSessionId = UUID()
let validProblemId = UUID()
// Simulate a list of valid sessions
let validSessions = [validSessionId]
// Simulate attempts - one valid, one orphaned
let validAttempt: [String: Any] = [
"id": UUID().uuidString,
"sessionId": validSessionId.uuidString,
"problemId": validProblemId.uuidString,
"result": "completed",
]
let orphanedAttempt: [String: Any] = [
"id": UUID().uuidString,
"sessionId": deletedSessionId.uuidString,
"problemId": validProblemId.uuidString,
"result": "completed",
]
let allAttempts = [validAttempt, orphanedAttempt]
// Filter to find orphaned attempts
let orphaned = allAttempts.filter { attempt in
guard let sessionIdString = attempt["sessionId"] as? String,
let sessionId = UUID(uuidString: sessionIdString)
else {
return false
}
return !validSessions.contains(sessionId)
}
XCTAssertEqual(orphaned.count, 1, "Should detect exactly one orphaned attempt")
XCTAssertEqual(orphaned[0]["sessionId"] as? String, deletedSessionId.uuidString)
}
func testOrphanedAttemptRemoval() throws {
// Test that orphaned attempts can be properly removed from a list
let validSessionId = UUID()
let deletedSessionId = UUID()
let problemId = UUID()
let validSessions = Set([validSessionId])
// Create test attempts
var attempts: [[String: Any]] = [
[
"id": UUID().uuidString,
"sessionId": validSessionId.uuidString,
"problemId": problemId.uuidString,
"result": "completed",
],
[
"id": UUID().uuidString,
"sessionId": deletedSessionId.uuidString,
"problemId": problemId.uuidString,
"result": "failed",
],
[
"id": UUID().uuidString,
"sessionId": validSessionId.uuidString,
"problemId": problemId.uuidString,
"result": "flash",
],
]
XCTAssertEqual(attempts.count, 3, "Should start with 3 attempts")
// Remove orphaned attempts
attempts.removeAll { attempt in
guard let sessionIdString = attempt["sessionId"] as? String,
let sessionId = UUID(uuidString: sessionIdString)
else {
return true
}
return !validSessions.contains(sessionId)
}
XCTAssertEqual(attempts.count, 2, "Should have 2 attempts after cleanup")
// Verify remaining attempts are all valid
for attempt in attempts {
if let sessionIdString = attempt["sessionId"] as? String,
let sessionId = UUID(uuidString: sessionIdString)
{
XCTAssertTrue(
validSessions.contains(sessionId),
"All remaining attempts should reference valid sessions")
}
}
}
func testCascadeDeleteSessionWithAttempts() throws {
// Test that deleting a session properly tracks all its attempts as deleted
let sessionId = UUID()
let problemId = UUID()
// Create attempts for this session
let sessionAttempts: [[String: Any]] = [
[
"id": UUID().uuidString, "sessionId": sessionId.uuidString,
"problemId": problemId.uuidString,
],
[
"id": UUID().uuidString, "sessionId": sessionId.uuidString,
"problemId": problemId.uuidString,
],
[
"id": UUID().uuidString, "sessionId": sessionId.uuidString,
"problemId": problemId.uuidString,
],
]
XCTAssertEqual(sessionAttempts.count, 3, "Session should have 3 attempts")
// Simulate tracking deletions
var deletedItems: [String] = []
// Add session to deleted items
deletedItems.append(sessionId.uuidString)
// Add all attempts to deleted items
for attempt in sessionAttempts {
if let attemptId = attempt["id"] as? String {
deletedItems.append(attemptId)
}
}
XCTAssertEqual(deletedItems.count, 4, "Should track 1 session + 3 attempts as deleted")
XCTAssertTrue(deletedItems.contains(sessionId.uuidString), "Should track session deletion")
// Verify all attempt IDs are tracked
let attemptIds = sessionAttempts.compactMap { $0["id"] as? String }
for attemptId in attemptIds {
XCTAssertTrue(
deletedItems.contains(attemptId), "Should track attempt \(attemptId) deletion")
}
}
func testDataIntegrityValidation() throws {
// Test data integrity validation logic
let gymId = UUID()
let sessionId = UUID()
let problemId = UUID()
// Valid data setup
let gyms = [gymId]
let sessions = [(id: sessionId, gymId: gymId)]
let problems = [(id: problemId, gymId: gymId)]
let attempts = [
(id: UUID(), sessionId: sessionId, problemId: problemId),
(id: UUID(), sessionId: sessionId, problemId: problemId),
]
// Validate that all relationships are correct
let validGyms = Set(gyms)
let validSessions = Set(sessions.map { $0.id })
let validProblems = Set(problems.map { $0.id })
// Check sessions reference valid gyms
for session in sessions {
XCTAssertTrue(validGyms.contains(session.gymId), "Session should reference valid gym")
}
// Check problems reference valid gyms
for problem in problems {
XCTAssertTrue(validGyms.contains(problem.gymId), "Problem should reference valid gym")
}
// Check attempts reference valid sessions and problems
for attempt in attempts {
XCTAssertTrue(
validSessions.contains(attempt.sessionId), "Attempt should reference valid session")
XCTAssertTrue(
validProblems.contains(attempt.problemId), "Attempt should reference valid problem")
}
// Test integrity check passes
let hasOrphanedSessions = sessions.contains { !validGyms.contains($0.gymId) }
let hasOrphanedProblems = problems.contains { !validGyms.contains($0.gymId) }
let hasOrphanedAttempts = attempts.contains {
!validSessions.contains($0.sessionId) || !validProblems.contains($0.problemId)
}
XCTAssertFalse(hasOrphanedSessions, "Should not have orphaned sessions")
XCTAssertFalse(hasOrphanedProblems, "Should not have orphaned problems")
XCTAssertFalse(hasOrphanedAttempts, "Should not have orphaned attempts")
}
} }

23
ios/README.md Normal file
View File

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

View File

@@ -5,8 +5,8 @@ import ActivityKit
import SwiftUI import SwiftUI
import WidgetKit import WidgetKit
struct SessionActivityAttributes: ActivityAttributes { struct SessionActivityAttributes: ActivityAttributes, Sendable {
public struct ContentState: Codable, Hashable { public struct ContentState: Codable, Hashable, Sendable {
var elapsed: TimeInterval var elapsed: TimeInterval
var totalAttempts: Int var totalAttempts: Int
var completedProblems: Int var completedProblems: Int

37
sync/README.md Normal file
View File

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