Compare commits
14 Commits
ANDROID_1.
...
ANDROID_1.
| Author | SHA1 | Date | |
|---|---|---|---|
|
30d2b3938e
|
|||
|
405fb06d5d
|
|||
|
77f8110d85
|
|||
|
53fa74cc83
|
|||
|
e7c46634da
|
|||
|
40efd6636f
|
|||
|
719181aa16
|
|||
|
790b7075c5
|
|||
|
ad8723b8fe
|
|||
|
6a39d23f28
|
|||
|
603a683ab2
|
|||
|
a19ff8ef66
|
|||
|
c10fa48bf5
|
|||
|
acf487db93
|
@@ -12,6 +12,7 @@ For Android do one of the following:
|
||||
For iOS:
|
||||
|
||||
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
|
||||
|
||||
|
||||
22
android/README.md
Normal 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.
|
||||
@@ -16,8 +16,8 @@ android {
|
||||
applicationId = "com.atridad.openclimb"
|
||||
minSdk = 31
|
||||
targetSdk = 36
|
||||
versionCode = 30
|
||||
versionName = "1.7.2"
|
||||
versionCode = 39
|
||||
versionName = "1.9.2"
|
||||
|
||||
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
||||
}
|
||||
@@ -60,6 +60,7 @@ dependencies {
|
||||
// Room Database
|
||||
implementation(libs.androidx.room.runtime)
|
||||
implementation(libs.androidx.room.ktx)
|
||||
implementation(libs.androidx.exifinterface)
|
||||
|
||||
ksp(libs.androidx.room.compiler)
|
||||
|
||||
@@ -78,6 +79,9 @@ dependencies {
|
||||
// Image Loading
|
||||
implementation(libs.coil.compose)
|
||||
|
||||
// Health Connect
|
||||
implementation("androidx.health.connect:connect-client:1.1.0-alpha07")
|
||||
|
||||
// Testing
|
||||
testImplementation(libs.junit)
|
||||
testImplementation(libs.mockk)
|
||||
|
||||
@@ -10,6 +10,17 @@
|
||||
|
||||
<!-- Permission for sync functionality -->
|
||||
<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 -->
|
||||
<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_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
|
||||
android:allowBackup="true"
|
||||
android:dataExtractionRules="@xml/data_extraction_rules"
|
||||
@@ -40,6 +63,16 @@
|
||||
<category android:name="android.intent.category.LAUNCHER" />
|
||||
</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>
|
||||
|
||||
|
||||
@@ -12,7 +12,15 @@ data class ClimbDataBackup(
|
||||
val gyms: List<BackupGym>,
|
||||
val problems: List<BackupProblem>,
|
||||
val sessions: List<BackupClimbSession>,
|
||||
val attempts: List<BackupAttempt>
|
||||
val attempts: List<BackupAttempt>,
|
||||
val deletedItems: List<DeletedItem> = emptyList()
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class DeletedItem(
|
||||
val id: String,
|
||||
val type: String, // "gym", "problem", "session", "attempt"
|
||||
val deletedAt: String
|
||||
)
|
||||
|
||||
// Platform-neutral gym representation for backup/restore
|
||||
|
||||
@@ -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")
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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()
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -207,7 +207,7 @@ data class DifficultyGrade(val system: DifficultySystem, val grade: String, val
|
||||
private fun compareVScaleGrades(grade1: String, grade2: String): Int {
|
||||
if (grade1 == "VB" && grade2 != "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 num2 = grade2.removePrefix("V").toIntOrNull() ?: 0
|
||||
|
||||
@@ -1,12 +1,15 @@
|
||||
package com.atridad.openclimb.data.repository
|
||||
|
||||
import android.content.Context
|
||||
import android.content.SharedPreferences
|
||||
import androidx.core.content.edit
|
||||
import com.atridad.openclimb.data.database.OpenClimbDatabase
|
||||
import com.atridad.openclimb.data.format.BackupAttempt
|
||||
import com.atridad.openclimb.data.format.BackupClimbSession
|
||||
import com.atridad.openclimb.data.format.BackupGym
|
||||
import com.atridad.openclimb.data.format.BackupProblem
|
||||
import com.atridad.openclimb.data.format.ClimbDataBackup
|
||||
import com.atridad.openclimb.data.format.DeletedItem
|
||||
import com.atridad.openclimb.data.model.*
|
||||
import com.atridad.openclimb.data.state.DataStateManager
|
||||
import com.atridad.openclimb.utils.DateFormatUtils
|
||||
@@ -22,6 +25,8 @@ class ClimbRepository(database: OpenClimbDatabase, private val context: Context)
|
||||
private val sessionDao = database.climbSessionDao()
|
||||
private val attemptDao = database.attemptDao()
|
||||
private val dataStateManager = DataStateManager(context)
|
||||
private val deletionPreferences: SharedPreferences =
|
||||
context.getSharedPreferences("deleted_items", Context.MODE_PRIVATE)
|
||||
|
||||
private var autoSyncCallback: (() -> Unit)? = null
|
||||
|
||||
@@ -45,6 +50,7 @@ class ClimbRepository(database: OpenClimbDatabase, private val context: Context)
|
||||
}
|
||||
suspend fun deleteGym(gym: Gym) {
|
||||
gymDao.deleteGym(gym)
|
||||
trackDeletion(gym.id, "gym")
|
||||
dataStateManager.updateDataState()
|
||||
triggerAutoSync()
|
||||
}
|
||||
@@ -56,17 +62,15 @@ class ClimbRepository(database: OpenClimbDatabase, private val context: Context)
|
||||
suspend fun insertProblem(problem: Problem) {
|
||||
problemDao.insertProblem(problem)
|
||||
dataStateManager.updateDataState()
|
||||
triggerAutoSync()
|
||||
}
|
||||
suspend fun updateProblem(problem: Problem) {
|
||||
problemDao.updateProblem(problem)
|
||||
dataStateManager.updateDataState()
|
||||
triggerAutoSync()
|
||||
}
|
||||
suspend fun deleteProblem(problem: Problem) {
|
||||
problemDao.deleteProblem(problem)
|
||||
trackDeletion(problem.id, "problem")
|
||||
dataStateManager.updateDataState()
|
||||
triggerAutoSync()
|
||||
}
|
||||
|
||||
// Session operations
|
||||
@@ -79,15 +83,22 @@ class ClimbRepository(database: OpenClimbDatabase, private val context: Context)
|
||||
suspend fun insertSession(session: ClimbSession) {
|
||||
sessionDao.insertSession(session)
|
||||
dataStateManager.updateDataState()
|
||||
triggerAutoSync()
|
||||
// Only trigger sync for completed sessions
|
||||
if (session.status != SessionStatus.ACTIVE) {
|
||||
triggerAutoSync()
|
||||
}
|
||||
}
|
||||
suspend fun updateSession(session: ClimbSession) {
|
||||
sessionDao.updateSession(session)
|
||||
dataStateManager.updateDataState()
|
||||
triggerAutoSync()
|
||||
// Only trigger sync for completed sessions
|
||||
if (session.status != SessionStatus.ACTIVE) {
|
||||
triggerAutoSync()
|
||||
}
|
||||
}
|
||||
suspend fun deleteSession(session: ClimbSession) {
|
||||
sessionDao.deleteSession(session)
|
||||
trackDeletion(session.id, "session")
|
||||
dataStateManager.updateDataState()
|
||||
triggerAutoSync()
|
||||
}
|
||||
@@ -109,17 +120,15 @@ class ClimbRepository(database: OpenClimbDatabase, private val context: Context)
|
||||
suspend fun insertAttempt(attempt: Attempt) {
|
||||
attemptDao.insertAttempt(attempt)
|
||||
dataStateManager.updateDataState()
|
||||
triggerAutoSync()
|
||||
}
|
||||
suspend fun updateAttempt(attempt: Attempt) {
|
||||
attemptDao.updateAttempt(attempt)
|
||||
dataStateManager.updateDataState()
|
||||
triggerAutoSync()
|
||||
}
|
||||
suspend fun deleteAttempt(attempt: Attempt) {
|
||||
attemptDao.deleteAttempt(attempt)
|
||||
trackDeletion(attempt.id, "attempt")
|
||||
dataStateManager.updateDataState()
|
||||
triggerAutoSync()
|
||||
}
|
||||
|
||||
suspend fun exportAllDataToZipUri(context: Context, uri: android.net.Uri) {
|
||||
@@ -258,6 +267,38 @@ class ClimbRepository(database: OpenClimbDatabase, private val context: Context)
|
||||
autoSyncCallback?.invoke()
|
||||
}
|
||||
|
||||
private fun trackDeletion(itemId: String, itemType: String) {
|
||||
val currentDeletions = getDeletedItems().toMutableList()
|
||||
val newDeletion =
|
||||
DeletedItem(id = itemId, type = itemType, deletedAt = DateFormatUtils.nowISO8601())
|
||||
currentDeletions.add(newDeletion)
|
||||
|
||||
val json = json.encodeToString(newDeletion)
|
||||
deletionPreferences.edit { putString("deleted_${itemId}", json) }
|
||||
}
|
||||
|
||||
fun getDeletedItems(): List<DeletedItem> {
|
||||
val deletions = mutableListOf<DeletedItem>()
|
||||
val allPrefs = deletionPreferences.all
|
||||
|
||||
for ((key, value) in allPrefs) {
|
||||
if (key.startsWith("deleted_") && value is String) {
|
||||
try {
|
||||
val deletion = json.decodeFromString<DeletedItem>(value)
|
||||
deletions.add(deletion)
|
||||
} catch (_: Exception) {
|
||||
// Invalid deletion record, ignore
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return deletions
|
||||
}
|
||||
|
||||
fun clearDeletedItems() {
|
||||
deletionPreferences.edit { clear() }
|
||||
}
|
||||
|
||||
private fun validateDataIntegrity(
|
||||
gyms: List<Gym>,
|
||||
problems: List<Problem>,
|
||||
|
||||
@@ -4,6 +4,7 @@ import android.content.Context
|
||||
import android.content.SharedPreferences
|
||||
import android.util.Log
|
||||
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
|
||||
@@ -35,7 +36,7 @@ class DataStateManager(context: Context) {
|
||||
*/
|
||||
fun updateDataState() {
|
||||
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")
|
||||
}
|
||||
|
||||
@@ -48,21 +49,6 @@ class DataStateManager(context: Context) {
|
||||
?: 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. */
|
||||
private fun isInitialized(): Boolean {
|
||||
return prefs.getBoolean(KEY_INITIALIZED, false)
|
||||
@@ -70,11 +56,7 @@ class DataStateManager(context: Context) {
|
||||
|
||||
/** Marks the data state as initialized. */
|
||||
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()})"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -88,11 +88,7 @@ class SessionTrackingService : Service() {
|
||||
|
||||
return START_REDELIVER_INTENT
|
||||
}
|
||||
|
||||
override fun onTaskRemoved(rootIntent: Intent?) {
|
||||
super.onTaskRemoved(rootIntent)
|
||||
}
|
||||
|
||||
|
||||
override fun onBind(intent: Intent?): IBinder? = null
|
||||
|
||||
private fun startSessionTracking(sessionId: String) {
|
||||
@@ -153,7 +149,7 @@ class SessionTrackingService : Service() {
|
||||
return try {
|
||||
val activeNotifications = notificationManager.activeNotifications
|
||||
activeNotifications.any { it.id == NOTIFICATION_ID }
|
||||
} catch (e: Exception) {
|
||||
} catch (_: Exception) {
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
@@ -45,7 +45,7 @@ fun OpenClimbApp(
|
||||
val repository = remember { ClimbRepository(database, context) }
|
||||
val syncService = remember { SyncService(context, repository) }
|
||||
val viewModel: ClimbViewModel =
|
||||
viewModel(factory = ClimbViewModelFactory(repository, syncService))
|
||||
viewModel(factory = ClimbViewModelFactory(repository, syncService, context))
|
||||
|
||||
var showNotificationPermissionDialog by remember { mutableStateOf(false) }
|
||||
var hasCheckedNotificationPermission by remember { mutableStateOf(false) }
|
||||
|
||||
@@ -3,7 +3,6 @@ package com.atridad.openclimb.ui.components
|
||||
import androidx.compose.foundation.ExperimentalFoundationApi
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.gestures.detectTransformGestures
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.lazy.LazyRow
|
||||
import androidx.compose.foundation.lazy.itemsIndexed
|
||||
@@ -20,140 +19,114 @@ import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
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.platform.LocalContext
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.window.Dialog
|
||||
import androidx.compose.ui.window.DialogProperties
|
||||
import coil.compose.AsyncImage
|
||||
import com.atridad.openclimb.utils.ImageUtils
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
@OptIn(ExperimentalFoundationApi::class)
|
||||
@Composable
|
||||
fun FullscreenImageViewer(
|
||||
imagePaths: List<String>,
|
||||
initialIndex: Int = 0,
|
||||
onDismiss: () -> Unit
|
||||
) {
|
||||
fun FullscreenImageViewer(imagePaths: List<String>, initialIndex: Int = 0, onDismiss: () -> Unit) {
|
||||
val context = LocalContext.current
|
||||
val pagerState = rememberPagerState(
|
||||
initialPage = initialIndex,
|
||||
pageCount = { imagePaths.size }
|
||||
)
|
||||
val pagerState = rememberPagerState(initialPage = initialIndex, pageCount = { imagePaths.size })
|
||||
val thumbnailListState = rememberLazyListState()
|
||||
val coroutineScope = rememberCoroutineScope()
|
||||
|
||||
|
||||
// Auto-scroll thumbnail list to center current image
|
||||
LaunchedEffect(pagerState.currentPage) {
|
||||
thumbnailListState.animateScrollToItem(
|
||||
index = pagerState.currentPage,
|
||||
scrollOffset = -200
|
||||
)
|
||||
thumbnailListState.animateScrollToItem(index = pagerState.currentPage, scrollOffset = -200)
|
||||
}
|
||||
|
||||
|
||||
Dialog(
|
||||
onDismissRequest = onDismiss,
|
||||
properties = DialogProperties(
|
||||
usePlatformDefaultWidth = false,
|
||||
decorFitsSystemWindows = false
|
||||
)
|
||||
onDismissRequest = onDismiss,
|
||||
properties =
|
||||
DialogProperties(
|
||||
usePlatformDefaultWidth = false,
|
||||
decorFitsSystemWindows = false
|
||||
)
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.background(Color.Black)
|
||||
) {
|
||||
Box(modifier = Modifier.fillMaxSize().background(Color.Black)) {
|
||||
// Main image pager
|
||||
HorizontalPager(
|
||||
state = pagerState,
|
||||
modifier = Modifier.fillMaxSize()
|
||||
) { page ->
|
||||
ZoomableImage(
|
||||
imagePath = imagePaths[page],
|
||||
modifier = Modifier.fillMaxSize()
|
||||
HorizontalPager(state = pagerState, modifier = Modifier.fillMaxSize()) { page ->
|
||||
OrientationAwareImage(
|
||||
imagePath = imagePaths[page],
|
||||
contentDescription = "Full screen image",
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
contentScale = ContentScale.Fit
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
// Close button
|
||||
IconButton(
|
||||
onClick = onDismiss,
|
||||
modifier = Modifier
|
||||
.align(Alignment.TopEnd)
|
||||
.padding(16.dp)
|
||||
.background(
|
||||
Color.Black.copy(alpha = 0.5f),
|
||||
CircleShape
|
||||
)
|
||||
) {
|
||||
Icon(
|
||||
Icons.Default.Close,
|
||||
contentDescription = "Close",
|
||||
tint = Color.White
|
||||
)
|
||||
}
|
||||
|
||||
onClick = onDismiss,
|
||||
modifier =
|
||||
Modifier.align(Alignment.TopEnd)
|
||||
.padding(16.dp)
|
||||
.background(Color.Black.copy(alpha = 0.5f), CircleShape)
|
||||
) { Icon(Icons.Default.Close, contentDescription = "Close", tint = Color.White) }
|
||||
|
||||
// Image counter
|
||||
if (imagePaths.size > 1) {
|
||||
Card(
|
||||
modifier = Modifier
|
||||
.align(Alignment.TopCenter)
|
||||
.padding(16.dp),
|
||||
colors = CardDefaults.cardColors(
|
||||
containerColor = Color.Black.copy(alpha = 0.7f)
|
||||
)
|
||||
modifier = Modifier.align(Alignment.TopCenter).padding(16.dp),
|
||||
colors =
|
||||
CardDefaults.cardColors(
|
||||
containerColor = Color.Black.copy(alpha = 0.7f)
|
||||
)
|
||||
) {
|
||||
Text(
|
||||
text = "${pagerState.currentPage + 1} / ${imagePaths.size}",
|
||||
color = Color.White,
|
||||
modifier = Modifier.padding(horizontal = 12.dp, vertical = 6.dp)
|
||||
text = "${pagerState.currentPage + 1} / ${imagePaths.size}",
|
||||
color = Color.White,
|
||||
modifier = Modifier.padding(horizontal = 12.dp, vertical = 6.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// Thumbnail strip (if multiple images)
|
||||
if (imagePaths.size > 1) {
|
||||
Card(
|
||||
modifier = Modifier
|
||||
.align(Alignment.BottomCenter)
|
||||
.fillMaxWidth()
|
||||
.padding(16.dp),
|
||||
colors = CardDefaults.cardColors(
|
||||
containerColor = Color.Black.copy(alpha = 0.7f)
|
||||
)
|
||||
modifier =
|
||||
Modifier.align(Alignment.BottomCenter)
|
||||
.fillMaxWidth()
|
||||
.padding(16.dp),
|
||||
colors =
|
||||
CardDefaults.cardColors(
|
||||
containerColor = Color.Black.copy(alpha = 0.7f)
|
||||
)
|
||||
) {
|
||||
LazyRow(
|
||||
state = thumbnailListState,
|
||||
modifier = Modifier.padding(8.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
contentPadding = PaddingValues(horizontal = 8.dp)
|
||||
state = thumbnailListState,
|
||||
modifier = Modifier.padding(8.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
contentPadding = PaddingValues(horizontal = 8.dp)
|
||||
) {
|
||||
itemsIndexed(imagePaths) { index, imagePath ->
|
||||
val imageFile = ImageUtils.getImageFile(context, imagePath)
|
||||
val isSelected = index == pagerState.currentPage
|
||||
|
||||
AsyncImage(
|
||||
model = imageFile,
|
||||
contentDescription = "Thumbnail ${index + 1}",
|
||||
modifier = Modifier
|
||||
.size(60.dp)
|
||||
.clip(RoundedCornerShape(8.dp))
|
||||
.clickable {
|
||||
coroutineScope.launch {
|
||||
pagerState.animateScrollToPage(index)
|
||||
}
|
||||
}
|
||||
.then(
|
||||
if (isSelected) {
|
||||
Modifier.background(
|
||||
Color.White.copy(alpha = 0.3f),
|
||||
RoundedCornerShape(8.dp)
|
||||
)
|
||||
} else Modifier
|
||||
),
|
||||
contentScale = ContentScale.Crop
|
||||
|
||||
OrientationAwareImage(
|
||||
imagePath = imagePath,
|
||||
contentDescription = "Thumbnail ${index + 1}",
|
||||
modifier =
|
||||
Modifier.size(60.dp)
|
||||
.clip(RoundedCornerShape(8.dp))
|
||||
.clickable {
|
||||
coroutineScope.launch {
|
||||
pagerState.animateScrollToPage(index)
|
||||
}
|
||||
}
|
||||
.then(
|
||||
if (isSelected) {
|
||||
Modifier.background(
|
||||
Color.White.copy(
|
||||
alpha = 0.3f
|
||||
),
|
||||
RoundedCornerShape(8.dp)
|
||||
)
|
||||
} 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
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,36 +12,29 @@ import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.unit.dp
|
||||
import coil.compose.AsyncImage
|
||||
import com.atridad.openclimb.utils.ImageUtils
|
||||
|
||||
@Composable
|
||||
fun ImageDisplay(
|
||||
imagePaths: List<String>,
|
||||
modifier: Modifier = Modifier,
|
||||
imageSize: Int = 120,
|
||||
onImageClick: ((Int) -> Unit)? = null
|
||||
imagePaths: List<String>,
|
||||
modifier: Modifier = Modifier,
|
||||
imageSize: Int = 120,
|
||||
onImageClick: ((Int) -> Unit)? = null
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
|
||||
|
||||
if (imagePaths.isNotEmpty()) {
|
||||
LazyRow(
|
||||
modifier = modifier,
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp)
|
||||
) {
|
||||
LazyRow(modifier = modifier, horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
itemsIndexed(imagePaths) { index, imagePath ->
|
||||
val imageFile = ImageUtils.getImageFile(context, imagePath)
|
||||
|
||||
AsyncImage(
|
||||
model = imageFile,
|
||||
contentDescription = "Problem photo",
|
||||
modifier = Modifier
|
||||
.size(imageSize.dp)
|
||||
.clip(RoundedCornerShape(8.dp))
|
||||
.clickable(enabled = onImageClick != null) {
|
||||
onImageClick?.invoke(index)
|
||||
},
|
||||
contentScale = ContentScale.Crop
|
||||
OrientationAwareImage(
|
||||
imagePath = imagePath,
|
||||
contentDescription = "Problem photo",
|
||||
modifier =
|
||||
Modifier.size(imageSize.dp)
|
||||
.clip(RoundedCornerShape(8.dp))
|
||||
.clickable(enabled = onImageClick != null) {
|
||||
onImageClick?.invoke(index)
|
||||
},
|
||||
contentScale = ContentScale.Crop
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -50,26 +43,22 @@ fun ImageDisplay(
|
||||
|
||||
@Composable
|
||||
fun ImageDisplaySection(
|
||||
imagePaths: List<String>,
|
||||
modifier: Modifier = Modifier,
|
||||
title: String = "Photos",
|
||||
onImageClick: ((Int) -> Unit)? = null
|
||||
imagePaths: List<String>,
|
||||
modifier: Modifier = Modifier,
|
||||
title: String = "Photos",
|
||||
onImageClick: ((Int) -> Unit)? = null
|
||||
) {
|
||||
if (imagePaths.isNotEmpty()) {
|
||||
Column(modifier = modifier) {
|
||||
Text(
|
||||
text = title,
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
color = MaterialTheme.colorScheme.onSurface
|
||||
text = title,
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
color = MaterialTheme.colorScheme.onSurface
|
||||
)
|
||||
|
||||
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
|
||||
ImageDisplay(
|
||||
imagePaths = imagePaths,
|
||||
imageSize = 120,
|
||||
onImageClick = onImageClick
|
||||
)
|
||||
|
||||
ImageDisplay(imagePaths = imagePaths, imageSize = 120, onImageClick = onImageClick)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
package com.atridad.openclimb.ui.components
|
||||
|
||||
import android.Manifest
|
||||
import android.content.pm.PackageManager
|
||||
import android.net.Uri
|
||||
import android.os.Environment
|
||||
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.compose.foundation.layout.*
|
||||
@@ -8,7 +12,9 @@ import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Add
|
||||
import androidx.compose.material.icons.filled.CameraAlt
|
||||
import androidx.compose.material.icons.filled.Close
|
||||
import androidx.compose.material.icons.filled.PhotoLibrary
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Alignment
|
||||
@@ -17,164 +23,261 @@ import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.unit.dp
|
||||
import coil.compose.AsyncImage
|
||||
import androidx.core.content.ContextCompat
|
||||
import androidx.core.content.FileProvider
|
||||
import com.atridad.openclimb.utils.ImageUtils
|
||||
import java.io.File
|
||||
import java.text.SimpleDateFormat
|
||||
import java.util.*
|
||||
|
||||
@Composable
|
||||
fun ImagePicker(
|
||||
imageUris: List<String>,
|
||||
onImagesChanged: (List<String>) -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
maxImages: Int = 5
|
||||
imageUris: List<String>,
|
||||
onImagesChanged: (List<String>) -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
maxImages: Int = 5
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
var tempImageUris by remember { mutableStateOf(imageUris) }
|
||||
|
||||
var showImageSourceDialog by remember { mutableStateOf(false) }
|
||||
var cameraImageUri by remember { mutableStateOf<Uri?>(null) }
|
||||
|
||||
// Image picker launcher
|
||||
val imagePickerLauncher = rememberLauncherForActivityResult(
|
||||
contract = ActivityResultContracts.GetMultipleContents()
|
||||
) { uris ->
|
||||
if (uris.isNotEmpty()) {
|
||||
val currentCount = tempImageUris.size
|
||||
val remainingSlots = maxImages - currentCount
|
||||
val urisToProcess = uris.take(remainingSlots)
|
||||
|
||||
// Process images
|
||||
val newImagePaths = mutableListOf<String>()
|
||||
urisToProcess.forEach { uri ->
|
||||
val imagePath = ImageUtils.saveImageFromUri(context, uri)
|
||||
if (imagePath != null) {
|
||||
newImagePaths.add(imagePath)
|
||||
val imagePickerLauncher =
|
||||
rememberLauncherForActivityResult(
|
||||
contract = ActivityResultContracts.GetMultipleContents()
|
||||
) { uris ->
|
||||
if (uris.isNotEmpty()) {
|
||||
val currentCount = tempImageUris.size
|
||||
val remainingSlots = maxImages - currentCount
|
||||
val urisToProcess = uris.take(remainingSlots)
|
||||
|
||||
// Process images
|
||||
val newImagePaths = mutableListOf<String>()
|
||||
urisToProcess.forEach { uri ->
|
||||
val imagePath = ImageUtils.saveTemporaryImageFromUri(context, uri)
|
||||
if (imagePath != null) {
|
||||
newImagePaths.add(imagePath)
|
||||
}
|
||||
}
|
||||
|
||||
if (newImagePaths.isNotEmpty()) {
|
||||
val updatedUris = tempImageUris + newImagePaths
|
||||
tempImageUris = updatedUris
|
||||
onImagesChanged(updatedUris)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (newImagePaths.isNotEmpty()) {
|
||||
val updatedUris = tempImageUris + newImagePaths
|
||||
tempImageUris = updatedUris
|
||||
onImagesChanged(updatedUris)
|
||||
|
||||
// Camera launcher
|
||||
val cameraLauncher =
|
||||
rememberLauncherForActivityResult(contract = ActivityResultContracts.TakePicture()) {
|
||||
success ->
|
||||
if (success) {
|
||||
cameraImageUri?.let { uri ->
|
||||
val imagePath = ImageUtils.saveTemporaryImageFromUri(context, uri)
|
||||
if (imagePath != null) {
|
||||
val updatedUris = tempImageUris + imagePath
|
||||
tempImageUris = updatedUris
|
||||
onImagesChanged(updatedUris)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// Camera permission launcher
|
||||
val cameraPermissionLauncher =
|
||||
rememberLauncherForActivityResult(
|
||||
contract = ActivityResultContracts.RequestPermission()
|
||||
) { isGranted ->
|
||||
if (isGranted) {
|
||||
// Create image file for camera
|
||||
val imageFile = createImageFile(context)
|
||||
val uri =
|
||||
FileProvider.getUriForFile(
|
||||
context,
|
||||
"${context.packageName}.fileprovider",
|
||||
imageFile
|
||||
)
|
||||
cameraImageUri = uri
|
||||
cameraLauncher.launch(uri)
|
||||
}
|
||||
}
|
||||
|
||||
Column(modifier = modifier) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Text(
|
||||
text = "Photos (${tempImageUris.size}/$maxImages)",
|
||||
style = MaterialTheme.typography.titleMedium
|
||||
text = "Photos (${tempImageUris.size}/$maxImages)",
|
||||
style = MaterialTheme.typography.titleMedium
|
||||
)
|
||||
|
||||
|
||||
if (tempImageUris.size < maxImages) {
|
||||
TextButton(
|
||||
onClick = {
|
||||
imagePickerLauncher.launch("image/*")
|
||||
}
|
||||
) {
|
||||
Icon(Icons.Default.Add, contentDescription = null, modifier = Modifier.size(16.dp))
|
||||
TextButton(onClick = { showImageSourceDialog = true }) {
|
||||
Icon(
|
||||
Icons.Default.Add,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(16.dp)
|
||||
)
|
||||
Spacer(modifier = Modifier.width(4.dp))
|
||||
Text("Add Photos")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
if (tempImageUris.isNotEmpty()) {
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
|
||||
LazyRow(
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp)
|
||||
) {
|
||||
|
||||
LazyRow(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
items(tempImageUris) { imagePath ->
|
||||
ImageItem(
|
||||
imagePath = imagePath,
|
||||
onRemove = {
|
||||
val updatedUris = tempImageUris.filter { it != imagePath }
|
||||
tempImageUris = updatedUris
|
||||
onImagesChanged(updatedUris)
|
||||
|
||||
// Delete the image file
|
||||
ImageUtils.deleteImage(context, imagePath)
|
||||
}
|
||||
imagePath = imagePath,
|
||||
onRemove = {
|
||||
val updatedUris = tempImageUris.filter { it != imagePath }
|
||||
tempImageUris = updatedUris
|
||||
onImagesChanged(updatedUris)
|
||||
|
||||
// Delete the image file
|
||||
ImageUtils.deleteImage(context, imagePath)
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
Card(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.height(100.dp),
|
||||
colors = CardDefaults.cardColors(
|
||||
containerColor = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.3f)
|
||||
)
|
||||
modifier = Modifier.fillMaxWidth().height(100.dp),
|
||||
colors =
|
||||
CardDefaults.cardColors(
|
||||
containerColor =
|
||||
MaterialTheme.colorScheme.surfaceVariant.copy(
|
||||
alpha = 0.3f
|
||||
)
|
||||
)
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Column(
|
||||
horizontalAlignment = Alignment.CenterHorizontally
|
||||
) {
|
||||
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
|
||||
Column(horizontalAlignment = Alignment.CenterHorizontally) {
|
||||
Icon(
|
||||
Icons.Default.Add,
|
||||
contentDescription = null,
|
||||
tint = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
Icons.Default.Add,
|
||||
contentDescription = null,
|
||||
tint = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
Spacer(modifier = Modifier.height(4.dp))
|
||||
Text(
|
||||
text = "Add photos of this problem",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
text = "Add photos of this problem",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Image Source Selection Dialog
|
||||
if (showImageSourceDialog) {
|
||||
AlertDialog(
|
||||
onDismissRequest = { showImageSourceDialog = false },
|
||||
title = { Text("Add Photo") },
|
||||
text = { Text("Choose how you'd like to add a photo") },
|
||||
confirmButton = {
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
TextButton(
|
||||
onClick = {
|
||||
showImageSourceDialog = false
|
||||
imagePickerLauncher.launch("image/*")
|
||||
}
|
||||
) {
|
||||
Icon(
|
||||
Icons.Default.PhotoLibrary,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(16.dp)
|
||||
)
|
||||
Spacer(modifier = Modifier.width(4.dp))
|
||||
Text("Gallery")
|
||||
}
|
||||
|
||||
TextButton(
|
||||
onClick = {
|
||||
showImageSourceDialog = false
|
||||
when (ContextCompat.checkSelfPermission(
|
||||
context,
|
||||
Manifest.permission.CAMERA
|
||||
)
|
||||
) {
|
||||
PackageManager.PERMISSION_GRANTED -> {
|
||||
// Create image file for camera
|
||||
val imageFile = createImageFile(context)
|
||||
val uri =
|
||||
FileProvider.getUriForFile(
|
||||
context,
|
||||
"${context.packageName}.fileprovider",
|
||||
imageFile
|
||||
)
|
||||
cameraImageUri = uri
|
||||
cameraLauncher.launch(uri)
|
||||
}
|
||||
else -> {
|
||||
cameraPermissionLauncher.launch(
|
||||
Manifest.permission.CAMERA
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
) {
|
||||
Icon(
|
||||
Icons.Default.CameraAlt,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(16.dp)
|
||||
)
|
||||
Spacer(modifier = Modifier.width(4.dp))
|
||||
Text("Camera")
|
||||
}
|
||||
}
|
||||
},
|
||||
dismissButton = {
|
||||
TextButton(onClick = { showImageSourceDialog = false }) { Text("Cancel") }
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun createImageFile(context: android.content.Context): File {
|
||||
val timeStamp = SimpleDateFormat("yyyyMMdd_HHmmss", Locale.getDefault()).format(Date())
|
||||
val imageFileName = "JPEG_${timeStamp}_"
|
||||
val storageDir = context.getExternalFilesDir(Environment.DIRECTORY_PICTURES)
|
||||
return File.createTempFile(imageFileName, ".jpg", storageDir)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ImageItem(
|
||||
imagePath: String,
|
||||
onRemove: () -> Unit,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
private fun ImageItem(imagePath: String, onRemove: () -> Unit, modifier: Modifier = Modifier) {
|
||||
val context = LocalContext.current
|
||||
val imageFile = ImageUtils.getImageFile(context, imagePath)
|
||||
|
||||
Box(
|
||||
modifier = modifier.size(80.dp)
|
||||
) {
|
||||
AsyncImage(
|
||||
model = imageFile,
|
||||
contentDescription = "Problem photo",
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.clip(RoundedCornerShape(8.dp)),
|
||||
contentScale = ContentScale.Crop
|
||||
|
||||
Box(modifier = modifier.size(80.dp)) {
|
||||
OrientationAwareImage(
|
||||
imagePath = imagePath,
|
||||
contentDescription = "Problem photo",
|
||||
modifier = Modifier.fillMaxSize().clip(RoundedCornerShape(8.dp)),
|
||||
contentScale = ContentScale.Crop
|
||||
)
|
||||
|
||||
IconButton(
|
||||
onClick = onRemove,
|
||||
modifier = Modifier
|
||||
.align(Alignment.TopEnd)
|
||||
.size(24.dp)
|
||||
) {
|
||||
|
||||
IconButton(onClick = onRemove, modifier = Modifier.align(Alignment.TopEnd).size(24.dp)) {
|
||||
Card(
|
||||
shape = RoundedCornerShape(12.dp),
|
||||
colors = CardDefaults.cardColors(
|
||||
containerColor = MaterialTheme.colorScheme.errorContainer
|
||||
)
|
||||
shape = RoundedCornerShape(12.dp),
|
||||
colors =
|
||||
CardDefaults.cardColors(
|
||||
containerColor = MaterialTheme.colorScheme.errorContainer
|
||||
)
|
||||
) {
|
||||
Icon(
|
||||
Icons.Default.Close,
|
||||
contentDescription = "Remove photo",
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(2.dp),
|
||||
tint = MaterialTheme.colorScheme.onErrorContainer
|
||||
Icons.Default.Close,
|
||||
contentDescription = "Remove photo",
|
||||
modifier = Modifier.fillMaxSize().padding(2.dp),
|
||||
tint = MaterialTheme.colorScheme.onErrorContainer
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -248,6 +248,7 @@ fun AddEditProblemScreen(
|
||||
) {
|
||||
val isEditing = problemId != null
|
||||
val gyms by viewModel.gyms.collectAsState()
|
||||
val context = LocalContext.current
|
||||
|
||||
// Problem form state
|
||||
var selectedGym by remember {
|
||||
@@ -387,10 +388,11 @@ fun AddEditProblemScreen(
|
||||
|
||||
if (isEditing) {
|
||||
viewModel.updateProblem(
|
||||
problem.copy(id = problemId!!)
|
||||
problem.copy(id = problemId),
|
||||
context
|
||||
)
|
||||
} else {
|
||||
viewModel.addProblem(problem)
|
||||
viewModel.addProblem(problem, context)
|
||||
}
|
||||
onNavigateBack()
|
||||
}
|
||||
|
||||
@@ -16,6 +16,7 @@ import androidx.compose.material.icons.filled.Add
|
||||
import androidx.compose.material.icons.filled.Check
|
||||
import androidx.compose.material.icons.filled.Delete
|
||||
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.KeyboardArrowUp
|
||||
import androidx.compose.material.icons.filled.Share
|
||||
@@ -260,6 +261,32 @@ fun SessionDetailScreen(
|
||||
}
|
||||
},
|
||||
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
|
||||
if (session?.duration != null) { // Only show for completed sessions
|
||||
IconButton(
|
||||
@@ -537,7 +564,7 @@ fun SessionDetailScreen(
|
||||
viewModel.addAttempt(attempt)
|
||||
showAddAttemptDialog = false
|
||||
},
|
||||
onProblemCreated = { problem -> viewModel.addProblem(problem) }
|
||||
onProblemCreated = { problem -> viewModel.addProblem(problem, context) }
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -4,19 +4,23 @@ import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.LazyRow
|
||||
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.runtime.*
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
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.Gym
|
||||
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.viewmodel.ClimbViewModel
|
||||
|
||||
@@ -25,9 +29,8 @@ import com.atridad.openclimb.ui.viewmodel.ClimbViewModel
|
||||
fun ProblemsScreen(viewModel: ClimbViewModel, onNavigateToProblemDetail: (String) -> Unit) {
|
||||
val problems by viewModel.problems.collectAsState()
|
||||
val gyms by viewModel.gyms.collectAsState()
|
||||
var showImageViewer by remember { mutableStateOf(false) }
|
||||
var selectedImagePaths by remember { mutableStateOf<List<String>>(emptyList()) }
|
||||
var selectedImageIndex by remember { mutableIntStateOf(0) }
|
||||
val attempts by viewModel.attempts.collectAsState()
|
||||
val context = LocalContext.current
|
||||
|
||||
// Filter state
|
||||
var selectedClimbType by remember { mutableStateOf<ClimbType?>(null) }
|
||||
@@ -176,15 +179,11 @@ fun ProblemsScreen(viewModel: ClimbViewModel, onNavigateToProblemDetail: (String
|
||||
ProblemCard(
|
||||
problem = problem,
|
||||
gymName = gyms.find { it.id == problem.gymId }?.name ?: "Unknown Gym",
|
||||
attempts = attempts,
|
||||
onClick = { onNavigateToProblemDetail(problem.id) },
|
||||
onImageClick = { imagePaths, index ->
|
||||
selectedImagePaths = imagePaths
|
||||
selectedImageIndex = index
|
||||
showImageViewer = true
|
||||
},
|
||||
onToggleActive = {
|
||||
val updatedProblem = problem.copy(isActive = !problem.isActive)
|
||||
viewModel.updateProblem(updatedProblem)
|
||||
viewModel.updateProblem(updatedProblem, context)
|
||||
}
|
||||
)
|
||||
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)
|
||||
@@ -208,10 +198,17 @@ fun ProblemsScreen(viewModel: ClimbViewModel, onNavigateToProblemDetail: (String
|
||||
fun ProblemCard(
|
||||
problem: Problem,
|
||||
gymName: String,
|
||||
attempts: List<Attempt>,
|
||||
onClick: () -> Unit,
|
||||
onImageClick: ((List<String>, Int) -> 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()) {
|
||||
Column(modifier = Modifier.fillMaxWidth().padding(16.dp)) {
|
||||
Row(
|
||||
@@ -240,12 +237,35 @@ fun ProblemCard(
|
||||
}
|
||||
|
||||
Column(horizontalAlignment = Alignment.End) {
|
||||
Text(
|
||||
text = problem.difficulty.grade,
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
fontWeight = FontWeight.Bold,
|
||||
color = MaterialTheme.colorScheme.primary
|
||||
)
|
||||
Row(
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
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 = 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) {
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
Text(
|
||||
|
||||
@@ -17,6 +17,7 @@ import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.atridad.openclimb.R
|
||||
import com.atridad.openclimb.ui.components.SyncIndicator
|
||||
import com.atridad.openclimb.ui.health.HealthConnectCard
|
||||
import com.atridad.openclimb.ui.viewmodel.ClimbViewModel
|
||||
import java.io.File
|
||||
import java.time.Instant
|
||||
@@ -37,12 +38,17 @@ fun SettingsScreen(viewModel: ClimbViewModel) {
|
||||
val isTesting by syncService.isTesting.collectAsState()
|
||||
val lastSyncTime by syncService.lastSyncTime.collectAsState()
|
||||
val syncError by syncService.syncError.collectAsState()
|
||||
val isAutoSyncEnabled by syncService.isAutoSyncEnabled.collectAsState()
|
||||
|
||||
// State for dialogs
|
||||
var showResetDialog by remember { mutableStateOf(false) }
|
||||
var showSyncConfigDialog 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
|
||||
var serverUrl by remember { mutableStateOf(syncService.serverURL) }
|
||||
var authToken by remember { mutableStateOf(syncService.authToken) }
|
||||
@@ -275,8 +281,10 @@ fun SettingsScreen(viewModel: ClimbViewModel) {
|
||||
}
|
||||
Spacer(modifier = Modifier.width(16.dp))
|
||||
Switch(
|
||||
checked = syncService.isAutoSyncEnabled,
|
||||
onCheckedChange = { syncService.isAutoSyncEnabled = it }
|
||||
checked = isAutoSyncEnabled,
|
||||
onCheckedChange = { enabled ->
|
||||
syncService.setAutoSyncEnabled(enabled)
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -375,7 +383,8 @@ fun SettingsScreen(viewModel: ClimbViewModel) {
|
||||
}
|
||||
}
|
||||
|
||||
// Data Management Section
|
||||
item { HealthConnectCard() }
|
||||
|
||||
item {
|
||||
Card(modifier = Modifier.fillMaxWidth(), shape = RoundedCornerShape(16.dp)) {
|
||||
Column(modifier = Modifier.fillMaxWidth().padding(16.dp)) {
|
||||
@@ -475,6 +484,48 @@ fun SettingsScreen(viewModel: ClimbViewModel) {
|
||||
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
|
||||
Card(
|
||||
shape = RoundedCornerShape(12.dp),
|
||||
colors =
|
||||
CardDefaults.cardColors(
|
||||
containerColor =
|
||||
MaterialTheme.colorScheme.errorContainer.copy(
|
||||
alpha = 0.3f
|
||||
)
|
||||
)
|
||||
) {
|
||||
ListItem(
|
||||
headlineContent = { Text("Delete All Images") },
|
||||
supportingContent = {
|
||||
Text("Permanently delete all image files from device")
|
||||
},
|
||||
leadingContent = {
|
||||
Icon(
|
||||
Icons.Default.Delete,
|
||||
contentDescription = null,
|
||||
tint = MaterialTheme.colorScheme.error
|
||||
)
|
||||
},
|
||||
trailingContent = {
|
||||
TextButton(
|
||||
onClick = { showDeleteImagesDialog = true },
|
||||
enabled = !isDeletingImages && !uiState.isLoading
|
||||
) {
|
||||
if (isDeletingImages) {
|
||||
CircularProgressIndicator(
|
||||
modifier = Modifier.size(16.dp),
|
||||
strokeWidth = 2.dp
|
||||
)
|
||||
} else {
|
||||
Text("Delete", color = MaterialTheme.colorScheme.error)
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
|
||||
Card(
|
||||
shape = RoundedCornerShape(12.dp),
|
||||
colors =
|
||||
@@ -903,16 +954,43 @@ fun SettingsScreen(viewModel: ClimbViewModel) {
|
||||
confirmButton = {
|
||||
TextButton(
|
||||
onClick = {
|
||||
syncService.clearConfiguration()
|
||||
serverUrl = ""
|
||||
authToken = ""
|
||||
viewModel.syncService.clearConfiguration()
|
||||
showDisconnectDialog = false
|
||||
}
|
||||
) { Text("Disconnect", color = MaterialTheme.colorScheme.error) }
|
||||
) { Text("Disconnect") }
|
||||
},
|
||||
dismissButton = {
|
||||
TextButton(onClick = { showDisconnectDialog = false }) { Text("Cancel") }
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
// 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") }
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,10 +3,12 @@ package com.atridad.openclimb.ui.viewmodel
|
||||
import android.content.Context
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.atridad.openclimb.data.health.HealthConnectManager
|
||||
import com.atridad.openclimb.data.model.*
|
||||
import com.atridad.openclimb.data.repository.ClimbRepository
|
||||
import com.atridad.openclimb.data.sync.SyncService
|
||||
import com.atridad.openclimb.service.SessionTrackingService
|
||||
import com.atridad.openclimb.utils.ImageNamingUtils
|
||||
import com.atridad.openclimb.utils.ImageUtils
|
||||
import com.atridad.openclimb.utils.SessionShareUtils
|
||||
import com.atridad.openclimb.widget.ClimbStatsWidgetProvider
|
||||
@@ -16,8 +18,14 @@ import kotlinx.coroutines.flow.*
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
|
||||
class ClimbViewModel(private val repository: ClimbRepository, val syncService: SyncService) :
|
||||
ViewModel() {
|
||||
class ClimbViewModel(
|
||||
private val repository: ClimbRepository,
|
||||
val syncService: SyncService,
|
||||
private val context: Context
|
||||
) : ViewModel() {
|
||||
|
||||
// Health Connect manager
|
||||
private val healthConnectManager = HealthConnectManager(context)
|
||||
|
||||
// UI State flows
|
||||
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)) }
|
||||
|
||||
// Problem operations
|
||||
fun addProblem(problem: Problem) {
|
||||
viewModelScope.launch { repository.insertProblem(problem) }
|
||||
}
|
||||
|
||||
fun addProblem(problem: Problem, context: Context) {
|
||||
viewModelScope.launch {
|
||||
repository.insertProblem(problem)
|
||||
val finalProblem = renameTemporaryImages(problem, context)
|
||||
repository.insertProblem(finalProblem)
|
||||
ClimbStatsWidgetProvider.updateAllWidgets(context)
|
||||
// Auto-sync now happens automatically via repository callback
|
||||
}
|
||||
}
|
||||
|
||||
fun updateProblem(problem: Problem) {
|
||||
viewModelScope.launch { repository.updateProblem(problem) }
|
||||
private suspend fun renameTemporaryImages(problem: Problem, context: Context? = null): Problem {
|
||||
if (problem.imagePaths.isEmpty()) {
|
||||
return problem
|
||||
}
|
||||
|
||||
val appContext = context ?: return problem
|
||||
val finalImagePaths = mutableListOf<String>()
|
||||
|
||||
problem.imagePaths.forEachIndexed { index, tempPath ->
|
||||
if (tempPath.startsWith("temp_")) {
|
||||
val deterministicName = ImageNamingUtils.generateImageFilename(problem.id, index)
|
||||
val finalPath =
|
||||
ImageUtils.renameTemporaryImage(appContext, tempPath, problem.id, index)
|
||||
finalImagePaths.add(finalPath ?: tempPath)
|
||||
} else {
|
||||
finalImagePaths.add(tempPath)
|
||||
}
|
||||
}
|
||||
|
||||
return problem.copy(imagePaths = finalImagePaths)
|
||||
}
|
||||
|
||||
fun updateProblem(problem: Problem, context: Context) {
|
||||
viewModelScope.launch {
|
||||
repository.updateProblem(problem)
|
||||
val finalProblem = renameTemporaryImages(problem, context)
|
||||
repository.updateProblem(finalProblem)
|
||||
ClimbStatsWidgetProvider.updateAllWidgets(context)
|
||||
}
|
||||
}
|
||||
@@ -148,6 +172,41 @@ class ClimbViewModel(private val repository: ClimbRepository, val syncService: S
|
||||
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 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)
|
||||
|
||||
android.util.Log.d("ClimbViewModel", "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)
|
||||
|
||||
// Auto-sync now happens automatically via repository callback
|
||||
syncToHealthConnect(completedSession)
|
||||
|
||||
_uiState.value = _uiState.value.copy(message = "Session completed!")
|
||||
}
|
||||
@@ -295,7 +353,6 @@ class ClimbViewModel(private val repository: ClimbRepository, val syncService: S
|
||||
viewModelScope.launch {
|
||||
repository.insertAttempt(attempt)
|
||||
ClimbStatsWidgetProvider.updateAllWidgets(context)
|
||||
// Auto-sync now happens automatically via repository callback
|
||||
}
|
||||
}
|
||||
|
||||
@@ -410,6 +467,10 @@ class ClimbViewModel(private val repository: ClimbRepository, val syncService: S
|
||||
_uiState.value = _uiState.value.copy(error = message)
|
||||
}
|
||||
|
||||
fun setMessage(message: String) {
|
||||
_uiState.value = _uiState.value.copy(message = message)
|
||||
}
|
||||
|
||||
fun resetAllData() {
|
||||
viewModelScope.launch {
|
||||
try {
|
||||
@@ -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
|
||||
suspend fun generateSessionShareCard(context: Context, sessionId: String): File? =
|
||||
withContext(Dispatchers.IO) {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
package com.atridad.openclimb.ui.viewmodel
|
||||
|
||||
import android.content.Context
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.ViewModelProvider
|
||||
import com.atridad.openclimb.data.repository.ClimbRepository
|
||||
@@ -7,13 +8,14 @@ import com.atridad.openclimb.data.sync.SyncService
|
||||
|
||||
class ClimbViewModelFactory(
|
||||
private val repository: ClimbRepository,
|
||||
private val syncService: SyncService
|
||||
private val syncService: SyncService,
|
||||
private val context: Context
|
||||
) : ViewModelProvider.Factory {
|
||||
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
override fun <T : ViewModel> create(modelClass: Class<T>): T {
|
||||
if (modelClass.isAssignableFrom(ClimbViewModel::class.java)) {
|
||||
return ClimbViewModel(repository, syncService) as T
|
||||
return ClimbViewModel(repository, syncService, context) as T
|
||||
}
|
||||
throw IllegalArgumentException("Unknown ViewModel class")
|
||||
}
|
||||
|
||||
@@ -13,18 +13,16 @@ object ImageNamingUtils {
|
||||
private const val HASH_LENGTH = 12 // First 12 chars of SHA-256
|
||||
|
||||
/** Generates a deterministic filename for a problem image */
|
||||
fun generateImageFilename(problemId: String, timestamp: String, imageIndex: Int): String {
|
||||
// Create a deterministic hash from problemId + timestamp + index
|
||||
val input = "${problemId}_${timestamp}_${imageIndex}"
|
||||
fun generateImageFilename(problemId: String, imageIndex: Int): String {
|
||||
val input = "${problemId}_${imageIndex}"
|
||||
val hash = createHash(input)
|
||||
|
||||
return "problem_${hash}_${imageIndex}${IMAGE_EXTENSION}"
|
||||
}
|
||||
|
||||
/** Generates a deterministic filename using current timestamp */
|
||||
fun generateImageFilename(problemId: String, imageIndex: Int): String {
|
||||
val timestamp = DateFormatUtils.nowISO8601()
|
||||
return generateImageFilename(problemId, timestamp, imageIndex)
|
||||
/** Legacy method for backward compatibility */
|
||||
fun generateImageFilename(problemId: String, timestamp: String, imageIndex: Int): String {
|
||||
return generateImageFilename(problemId, imageIndex)
|
||||
}
|
||||
|
||||
/** Extracts problem ID from an image filename */
|
||||
@@ -41,9 +39,7 @@ object ImageNamingUtils {
|
||||
return null
|
||||
}
|
||||
|
||||
// We can't extract the original problem ID from the hash,
|
||||
// but we can validate the format
|
||||
return parts[1] // Return the hash as identifier
|
||||
return parts[1]
|
||||
}
|
||||
|
||||
/** Validates if a filename follows our naming convention */
|
||||
@@ -63,15 +59,11 @@ object ImageNamingUtils {
|
||||
|
||||
/** Migrates an existing filename to our naming convention */
|
||||
fun migrateFilename(oldFilename: String, problemId: String, imageIndex: Int): String {
|
||||
// If it's already using our convention, keep it
|
||||
if (isValidImageFilename(oldFilename)) {
|
||||
return oldFilename
|
||||
}
|
||||
|
||||
// Generate new deterministic name
|
||||
// Use a timestamp based on the old filename to maintain some consistency
|
||||
val timestamp = DateFormatUtils.nowISO8601()
|
||||
return generateImageFilename(problemId, timestamp, imageIndex)
|
||||
return generateImageFilename(problemId, imageIndex)
|
||||
}
|
||||
|
||||
/** Creates a deterministic hash from input string */
|
||||
@@ -90,7 +82,7 @@ object ImageNamingUtils {
|
||||
val renameMap = mutableMapOf<String, String>()
|
||||
|
||||
existingFilenames.forEachIndexed { index, oldFilename ->
|
||||
val newFilename = migrateFilename(oldFilename, problemId, index)
|
||||
val newFilename = generateImageFilename(problemId, index)
|
||||
if (newFilename != oldFilename) {
|
||||
renameMap[oldFilename] = newFilename
|
||||
}
|
||||
@@ -98,4 +90,37 @@ object ImageNamingUtils {
|
||||
|
||||
return renameMap
|
||||
}
|
||||
|
||||
/** Generates the canonical filename for a problem image */
|
||||
fun getCanonicalImageFilename(problemId: String, imageIndex: Int): String {
|
||||
return generateImageFilename(problemId, imageIndex)
|
||||
}
|
||||
|
||||
/** Creates a mapping of existing server filenames to canonical filenames */
|
||||
fun createServerMigrationMap(
|
||||
problemId: String,
|
||||
serverImageFilenames: List<String>,
|
||||
localImageCount: Int
|
||||
): Map<String, String> {
|
||||
val migrationMap = mutableMapOf<String, String>()
|
||||
|
||||
for (imageIndex in 0 until localImageCount) {
|
||||
val canonicalName = getCanonicalImageFilename(problemId, imageIndex)
|
||||
|
||||
if (serverImageFilenames.contains(canonicalName)) {
|
||||
continue
|
||||
}
|
||||
|
||||
for (serverFilename in serverImageFilenames) {
|
||||
if (isValidImageFilename(serverFilename) &&
|
||||
!migrationMap.values.contains(serverFilename)
|
||||
) {
|
||||
migrationMap[serverFilename] = canonicalName
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return migrationMap
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,7 +5,10 @@ import android.content.Context
|
||||
import android.graphics.Bitmap
|
||||
import android.graphics.BitmapFactory
|
||||
import android.net.Uri
|
||||
import android.provider.MediaStore
|
||||
import android.util.Log
|
||||
import androidx.core.graphics.scale
|
||||
import androidx.exifinterface.media.ExifInterface
|
||||
import java.io.File
|
||||
import java.io.FileOutputStream
|
||||
import java.util.UUID
|
||||
@@ -17,7 +20,7 @@ object ImageUtils {
|
||||
private const val IMAGE_QUALITY = 85
|
||||
|
||||
// Creates the images directory if it doesn't exist
|
||||
private fun getImagesDirectory(context: Context): File {
|
||||
fun getImagesDirectory(context: Context): File {
|
||||
val imagesDir = File(context.filesDir, IMAGES_DIR)
|
||||
if (!imagesDir.exists()) {
|
||||
imagesDir.mkdirs()
|
||||
@@ -25,7 +28,57 @@ object ImageUtils {
|
||||
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(
|
||||
context: Context,
|
||||
imageUri: Uri,
|
||||
@@ -40,26 +93,18 @@ object ImageUtils {
|
||||
}
|
||||
?: return null
|
||||
|
||||
val orientedBitmap = correctImageOrientation(context, imageUri, originalBitmap)
|
||||
val compressedBitmap = compressImage(orientedBitmap)
|
||||
// Always require deterministic naming
|
||||
require(problemId != null && imageIndex != null) {
|
||||
"Problem ID and image index are required for deterministic image naming"
|
||||
}
|
||||
|
||||
val filename =
|
||||
if (problemId != null && imageIndex != null) {
|
||||
ImageNamingUtils.generateImageFilename(problemId, imageIndex)
|
||||
} else {
|
||||
"${UUID.randomUUID()}.jpg"
|
||||
}
|
||||
val filename = ImageNamingUtils.generateImageFilename(problemId, imageIndex)
|
||||
val imageFile = File(getImagesDirectory(context), filename)
|
||||
|
||||
FileOutputStream(imageFile).use { output ->
|
||||
compressedBitmap.compress(Bitmap.CompressFormat.JPEG, IMAGE_QUALITY, output)
|
||||
}
|
||||
|
||||
val success = saveImageWithExif(context, imageUri, originalBitmap, imageFile)
|
||||
originalBitmap.recycle()
|
||||
if (orientedBitmap != originalBitmap) {
|
||||
orientedBitmap.recycle()
|
||||
}
|
||||
compressedBitmap.recycle()
|
||||
|
||||
if (!success) return null
|
||||
|
||||
"$IMAGES_DIR/$filename"
|
||||
} catch (e: Exception) {
|
||||
@@ -73,35 +118,35 @@ object ImageUtils {
|
||||
return try {
|
||||
val inputStream = context.contentResolver.openInputStream(imageUri)
|
||||
inputStream?.use { input ->
|
||||
val exif = android.media.ExifInterface(input)
|
||||
val exif = androidx.exifinterface.media.ExifInterface(input)
|
||||
val orientation =
|
||||
exif.getAttributeInt(
|
||||
android.media.ExifInterface.TAG_ORIENTATION,
|
||||
android.media.ExifInterface.ORIENTATION_NORMAL
|
||||
androidx.exifinterface.media.ExifInterface.TAG_ORIENTATION,
|
||||
androidx.exifinterface.media.ExifInterface.ORIENTATION_NORMAL
|
||||
)
|
||||
|
||||
val matrix = android.graphics.Matrix()
|
||||
when (orientation) {
|
||||
android.media.ExifInterface.ORIENTATION_ROTATE_90 -> {
|
||||
androidx.exifinterface.media.ExifInterface.ORIENTATION_ROTATE_90 -> {
|
||||
matrix.postRotate(90f)
|
||||
}
|
||||
android.media.ExifInterface.ORIENTATION_ROTATE_180 -> {
|
||||
androidx.exifinterface.media.ExifInterface.ORIENTATION_ROTATE_180 -> {
|
||||
matrix.postRotate(180f)
|
||||
}
|
||||
android.media.ExifInterface.ORIENTATION_ROTATE_270 -> {
|
||||
androidx.exifinterface.media.ExifInterface.ORIENTATION_ROTATE_270 -> {
|
||||
matrix.postRotate(270f)
|
||||
}
|
||||
android.media.ExifInterface.ORIENTATION_FLIP_HORIZONTAL -> {
|
||||
androidx.exifinterface.media.ExifInterface.ORIENTATION_FLIP_HORIZONTAL -> {
|
||||
matrix.postScale(-1f, 1f)
|
||||
}
|
||||
android.media.ExifInterface.ORIENTATION_FLIP_VERTICAL -> {
|
||||
androidx.exifinterface.media.ExifInterface.ORIENTATION_FLIP_VERTICAL -> {
|
||||
matrix.postScale(1f, -1f)
|
||||
}
|
||||
android.media.ExifInterface.ORIENTATION_TRANSPOSE -> {
|
||||
androidx.exifinterface.media.ExifInterface.ORIENTATION_TRANSPOSE -> {
|
||||
matrix.postRotate(90f)
|
||||
matrix.postScale(-1f, 1f)
|
||||
}
|
||||
android.media.ExifInterface.ORIENTATION_TRANSVERSE -> {
|
||||
androidx.exifinterface.media.ExifInterface.ORIENTATION_TRANSVERSE -> {
|
||||
matrix.postRotate(-90f)
|
||||
matrix.postScale(-1f, 1f)
|
||||
}
|
||||
@@ -212,6 +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 */
|
||||
fun saveImageFromBytes(context: Context, imageData: ByteArray): String? {
|
||||
return try {
|
||||
@@ -247,21 +348,40 @@ object ImageUtils {
|
||||
filename: String
|
||||
): String? {
|
||||
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)
|
||||
|
||||
// Save compressed image
|
||||
FileOutputStream(imageFile).use { output ->
|
||||
compressedBitmap.compress(Bitmap.CompressFormat.JPEG, IMAGE_QUALITY, output)
|
||||
}
|
||||
// Check if image is too large and needs compression
|
||||
if (imageData.size > 5 * 1024 * 1024) { // 5MB threshold
|
||||
// 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
|
||||
bitmap.recycle()
|
||||
compressedBitmap.recycle()
|
||||
// Save compressed image
|
||||
FileOutputStream(imageFile).use { output ->
|
||||
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
|
||||
"$IMAGES_DIR/$filename"
|
||||
|
||||
@@ -454,4 +454,61 @@ class SyncMergeLogicTest {
|
||||
dateString1 > dateString2
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `test active sessions excluded from sync`() {
|
||||
// Test scenario: Active sessions should not be included in sync data
|
||||
// This tests the new behavior where active sessions are excluded from sync
|
||||
// until they are completed
|
||||
|
||||
val allLocalSessions =
|
||||
listOf(
|
||||
BackupClimbSession(
|
||||
id = "active_session_1",
|
||||
gymId = "gym1",
|
||||
date = "2024-01-01",
|
||||
startTime = "2024-01-01T10:00:00",
|
||||
endTime = null,
|
||||
duration = null,
|
||||
status = SessionStatus.ACTIVE,
|
||||
notes = null,
|
||||
createdAt = "2024-01-01T10:00:00",
|
||||
updatedAt = "2024-01-01T10:00:00"
|
||||
),
|
||||
BackupClimbSession(
|
||||
id = "completed_session_1",
|
||||
gymId = "gym1",
|
||||
date = "2023-12-31",
|
||||
startTime = "2023-12-31T15:00:00",
|
||||
endTime = "2023-12-31T17:00:00",
|
||||
duration = 7200000,
|
||||
status = SessionStatus.COMPLETED,
|
||||
notes = "Previous session",
|
||||
createdAt = "2023-12-31T15:00:00",
|
||||
updatedAt = "2023-12-31T17:00:00"
|
||||
)
|
||||
)
|
||||
|
||||
// Simulate filtering that would happen in createBackupFromRepository
|
||||
val sessionsForSync = allLocalSessions.filter { it.status != SessionStatus.ACTIVE }
|
||||
|
||||
// Only completed sessions should be included in sync
|
||||
assertEquals("Should only include completed sessions in sync", 1, sessionsForSync.size)
|
||||
|
||||
// Active session should be excluded
|
||||
assertFalse(
|
||||
"Should not contain active session in sync",
|
||||
sessionsForSync.any {
|
||||
it.id == "active_session_1" && it.status == SessionStatus.ACTIVE
|
||||
}
|
||||
)
|
||||
|
||||
// Completed session should be included
|
||||
assertTrue(
|
||||
"Should contain completed session in sync",
|
||||
sessionsForSync.any {
|
||||
it.id == "completed_session_1" && it.status == SessionStatus.COMPLETED
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,14 +11,15 @@ androidxTestRunner = "1.7.0"
|
||||
androidxTestRules = "1.7.0"
|
||||
lifecycleRuntimeKtx = "2.9.4"
|
||||
activityCompose = "1.11.0"
|
||||
composeBom = "2025.09.01"
|
||||
room = "2.8.1"
|
||||
composeBom = "2025.10.00"
|
||||
room = "2.8.2"
|
||||
navigation = "2.9.5"
|
||||
viewmodel = "2.9.4"
|
||||
kotlinxSerialization = "1.9.0"
|
||||
kotlinxCoroutines = "1.10.2"
|
||||
coil = "2.7.0"
|
||||
ksp = "2.2.20-2.0.3"
|
||||
exifinterface = "1.3.6"
|
||||
|
||||
[libraries]
|
||||
androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" }
|
||||
@@ -66,6 +67,7 @@ mockk = { group = "io.mockk", name = "mockk", version = "1.14.6" }
|
||||
|
||||
# Image Loading
|
||||
coil-compose = { group = "io.coil-kt", name = "coil-compose", version.ref = "coil" }
|
||||
androidx-exifinterface = { group = "androidx.exifinterface", name = "exifinterface", version.ref = "exifinterface" }
|
||||
|
||||
[plugins]
|
||||
android-application = { id = "com.android.application", version.ref = "agp" }
|
||||
|
||||
@@ -465,8 +465,9 @@
|
||||
CODE_SIGN_ENTITLEMENTS = OpenClimb/OpenClimb.entitlements;
|
||||
CODE_SIGN_IDENTITY = "Apple Development";
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 14;
|
||||
CURRENT_PROJECT_VERSION = 25;
|
||||
DEVELOPMENT_TEAM = 4BC9Y2LL4B;
|
||||
DRIVERKIT_DEPLOYMENT_TARGET = 24.6;
|
||||
ENABLE_PREVIEWS = YES;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
INFOPLIST_FILE = OpenClimb/Info.plist;
|
||||
@@ -485,7 +486,8 @@
|
||||
"$(inherited)",
|
||||
"@executable_path/Frameworks",
|
||||
);
|
||||
MARKETING_VERSION = 1.2.3;
|
||||
MACOSX_DEPLOYMENT_TARGET = 15.6;
|
||||
MARKETING_VERSION = 1.4.0;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.atridad.OpenClimb;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
PROVISIONING_PROFILE_SPECIFIER = "";
|
||||
@@ -495,8 +497,11 @@
|
||||
SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor;
|
||||
SWIFT_EMIT_LOC_STRINGS = YES;
|
||||
SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES;
|
||||
SWIFT_VERSION = 5.0;
|
||||
SWIFT_VERSION = 6.0;
|
||||
TARGETED_DEVICE_FAMILY = 1;
|
||||
TVOS_DEPLOYMENT_TARGET = 18.6;
|
||||
WATCHOS_DEPLOYMENT_TARGET = 11.6;
|
||||
XROS_DEPLOYMENT_TARGET = 2.6;
|
||||
};
|
||||
name = Debug;
|
||||
};
|
||||
@@ -508,8 +513,9 @@
|
||||
CODE_SIGN_ENTITLEMENTS = OpenClimb/OpenClimb.entitlements;
|
||||
CODE_SIGN_IDENTITY = "Apple Development";
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 14;
|
||||
CURRENT_PROJECT_VERSION = 25;
|
||||
DEVELOPMENT_TEAM = 4BC9Y2LL4B;
|
||||
DRIVERKIT_DEPLOYMENT_TARGET = 24.6;
|
||||
ENABLE_PREVIEWS = YES;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
INFOPLIST_FILE = OpenClimb/Info.plist;
|
||||
@@ -528,7 +534,8 @@
|
||||
"$(inherited)",
|
||||
"@executable_path/Frameworks",
|
||||
);
|
||||
MARKETING_VERSION = 1.2.3;
|
||||
MACOSX_DEPLOYMENT_TARGET = 15.6;
|
||||
MARKETING_VERSION = 1.4.0;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.atridad.OpenClimb;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
PROVISIONING_PROFILE_SPECIFIER = "";
|
||||
@@ -538,8 +545,11 @@
|
||||
SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor;
|
||||
SWIFT_EMIT_LOC_STRINGS = YES;
|
||||
SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES;
|
||||
SWIFT_VERSION = 5.0;
|
||||
SWIFT_VERSION = 6.0;
|
||||
TARGETED_DEVICE_FAMILY = 1;
|
||||
TVOS_DEPLOYMENT_TARGET = 18.6;
|
||||
WATCHOS_DEPLOYMENT_TARGET = 11.6;
|
||||
XROS_DEPLOYMENT_TARGET = 2.6;
|
||||
};
|
||||
name = Release;
|
||||
};
|
||||
@@ -592,7 +602,7 @@
|
||||
ASSETCATALOG_COMPILER_WIDGET_BACKGROUND_COLOR_NAME = WidgetBackground;
|
||||
CODE_SIGN_ENTITLEMENTS = SessionStatusLiveExtension.entitlements;
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 14;
|
||||
CURRENT_PROJECT_VERSION = 25;
|
||||
DEVELOPMENT_TEAM = 4BC9Y2LL4B;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
INFOPLIST_FILE = SessionStatusLive/Info.plist;
|
||||
@@ -603,7 +613,7 @@
|
||||
"@executable_path/Frameworks",
|
||||
"@executable_path/../../Frameworks",
|
||||
);
|
||||
MARKETING_VERSION = 1.2.3;
|
||||
MARKETING_VERSION = 1.4.0;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.atridad.OpenClimb.SessionStatusLive;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
SKIP_INSTALL = YES;
|
||||
@@ -622,7 +632,7 @@
|
||||
ASSETCATALOG_COMPILER_WIDGET_BACKGROUND_COLOR_NAME = WidgetBackground;
|
||||
CODE_SIGN_ENTITLEMENTS = SessionStatusLiveExtension.entitlements;
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 14;
|
||||
CURRENT_PROJECT_VERSION = 25;
|
||||
DEVELOPMENT_TEAM = 4BC9Y2LL4B;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
INFOPLIST_FILE = SessionStatusLive/Info.plist;
|
||||
@@ -633,7 +643,7 @@
|
||||
"@executable_path/Frameworks",
|
||||
"@executable_path/../../Frameworks",
|
||||
);
|
||||
MARKETING_VERSION = 1.2.3;
|
||||
MARKETING_VERSION = 1.4.0;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.atridad.OpenClimb.SessionStatusLive;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
SKIP_INSTALL = YES;
|
||||
|
||||
|
Before Width: | Height: | Size: 22 KiB After Width: | Height: | Size: 20 KiB |
|
Before Width: | Height: | Size: 36 KiB After Width: | Height: | Size: 35 KiB |
|
Before Width: | Height: | Size: 12 KiB After Width: | Height: | Size: 8.4 KiB |
@@ -1,18 +1,22 @@
|
||||
<?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"/>
|
||||
|
||||
<g transform="translate(512, 512) scale(2.5)">
|
||||
<polygon points="-70,80 -20,-60 30,80"
|
||||
<!-- Transform to match Android layout exactly -->
|
||||
<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"
|
||||
stroke="#1C1C1C"
|
||||
stroke-width="4"
|
||||
stroke="#FFFFFF"
|
||||
stroke-width="3"
|
||||
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"
|
||||
stroke="#1C1C1C"
|
||||
stroke-width="4"
|
||||
stroke="#FFFFFF"
|
||||
stroke-width="3"
|
||||
stroke-linejoin="round"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
||||
|
Before Width: | Height: | Size: 604 B After Width: | Height: | Size: 913 B |
@@ -1,18 +1,22 @@
|
||||
<?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"/>
|
||||
|
||||
<g transform="translate(512, 512) scale(2.5)">
|
||||
<polygon points="-70,80 -20,-60 30,80"
|
||||
<!-- Transform to match Android layout exactly -->
|
||||
<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"
|
||||
stroke="#1C1C1C"
|
||||
stroke-width="4"
|
||||
stroke-width="3"
|
||||
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"
|
||||
stroke="#1C1C1C"
|
||||
stroke-width="4"
|
||||
stroke-width="3"
|
||||
stroke-linejoin="round"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
||||
|
Before Width: | Height: | Size: 604 B After Width: | Height: | Size: 878 B |
@@ -1,19 +1,23 @@
|
||||
<?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"/>
|
||||
|
||||
<g transform="translate(512, 512) scale(2.5)">
|
||||
<polygon points="-70,80 -20,-60 30,80"
|
||||
<!-- Transform to match Android layout exactly -->
|
||||
<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"
|
||||
stroke="#000000"
|
||||
stroke-width="4"
|
||||
stroke-width="3"
|
||||
stroke-linejoin="round"
|
||||
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"
|
||||
stroke="#000000"
|
||||
stroke-width="4"
|
||||
stroke-width="3"
|
||||
stroke-linejoin="round"
|
||||
opacity="0.9"/>
|
||||
</g>
|
||||
|
||||
|
Before Width: | Height: | Size: 662 B After Width: | Height: | Size: 981 B |
56
ios/OpenClimb/Assets.xcassets/AppLogo.imageset/Contents.json
vendored
Normal 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
|
||||
}
|
||||
}
|
||||
BIN
ios/OpenClimb/Assets.xcassets/AppLogo.imageset/app_logo_256.png
vendored
Normal file
|
After Width: | Height: | Size: 3.1 KiB |
BIN
ios/OpenClimb/Assets.xcassets/AppLogo.imageset/app_logo_256_dark.png
vendored
Normal file
|
After Width: | Height: | Size: 3.1 KiB |
@@ -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
|
||||
}
|
||||
}
|
||||
|
Before Width: | Height: | Size: 4.0 KiB |
|
Before Width: | Height: | Size: 3.7 KiB |
39
ios/OpenClimb/Components/AsyncImageView.swift
Normal 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
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
54
ios/OpenClimb/Components/CameraImagePicker.swift
Normal file
@@ -0,0 +1,54 @@
|
||||
import SwiftUI
|
||||
import UIKit
|
||||
|
||||
struct CameraImagePicker: UIViewControllerRepresentable {
|
||||
@Binding var isPresented: Bool
|
||||
let onImageCaptured: (UIImage) -> Void
|
||||
|
||||
func makeUIViewController(context: Context) -> UIImagePickerController {
|
||||
let picker = UIImagePickerController()
|
||||
picker.delegate = context.coordinator
|
||||
picker.sourceType = .camera
|
||||
picker.cameraCaptureMode = .photo
|
||||
picker.cameraDevice = .rear
|
||||
picker.allowsEditing = false
|
||||
return picker
|
||||
}
|
||||
|
||||
func updateUIViewController(_ uiViewController: UIImagePickerController, context: Context) {
|
||||
// Nothing here actually... Q_Q
|
||||
}
|
||||
|
||||
func makeCoordinator() -> Coordinator {
|
||||
Coordinator(self)
|
||||
}
|
||||
|
||||
class Coordinator: NSObject, UIImagePickerControllerDelegate, UINavigationControllerDelegate {
|
||||
let parent: CameraImagePicker
|
||||
|
||||
init(_ parent: CameraImagePicker) {
|
||||
self.parent = parent
|
||||
}
|
||||
|
||||
func imagePickerController(
|
||||
_ picker: UIImagePickerController,
|
||||
didFinishPickingMediaWithInfo info: [UIImagePickerController.InfoKey: Any]
|
||||
) {
|
||||
if let image = info[.originalImage] as? UIImage {
|
||||
parent.onImageCaptured(image)
|
||||
}
|
||||
parent.isPresented = false
|
||||
}
|
||||
|
||||
func imagePickerControllerDidCancel(_ picker: UIImagePickerController) {
|
||||
parent.isPresented = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Extension to check camera availability
|
||||
extension CameraImagePicker {
|
||||
static var isCameraAvailable: Bool {
|
||||
UIImagePickerController.isSourceTypeAvailable(.camera)
|
||||
}
|
||||
}
|
||||
85
ios/OpenClimb/Components/PhotoOptionSheet.swift
Normal file
@@ -0,0 +1,85 @@
|
||||
import PhotosUI
|
||||
import SwiftUI
|
||||
|
||||
struct PhotoOptionSheet: View {
|
||||
@Binding var selectedPhotos: [PhotosPickerItem]
|
||||
@Binding var imageData: [Data]
|
||||
let maxImages: Int
|
||||
let onCameraSelected: () -> Void
|
||||
let onPhotoLibrarySelected: () -> Void
|
||||
let onDismiss: () -> Void
|
||||
|
||||
var body: some View {
|
||||
NavigationView {
|
||||
VStack(spacing: 20) {
|
||||
Text("Add Photo")
|
||||
.font(.title2)
|
||||
.fontWeight(.semibold)
|
||||
.padding(.top)
|
||||
|
||||
Text("Choose how you'd like to add a photo")
|
||||
.font(.subheadline)
|
||||
.foregroundColor(.secondary)
|
||||
|
||||
VStack(spacing: 16) {
|
||||
Button(action: {
|
||||
onPhotoLibrarySelected()
|
||||
onDismiss()
|
||||
}) {
|
||||
HStack {
|
||||
Image(systemName: "photo.on.rectangle")
|
||||
.font(.title2)
|
||||
.foregroundColor(.blue)
|
||||
Text("Photo Library")
|
||||
.font(.headline)
|
||||
Spacer()
|
||||
Image(systemName: "chevron.right")
|
||||
.font(.caption)
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
.padding()
|
||||
.background(.regularMaterial)
|
||||
.cornerRadius(12)
|
||||
}
|
||||
.buttonStyle(PlainButtonStyle())
|
||||
|
||||
Button(action: {
|
||||
onDismiss()
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) {
|
||||
onCameraSelected()
|
||||
}
|
||||
}) {
|
||||
HStack {
|
||||
Image(systemName: "camera.fill")
|
||||
.font(.title2)
|
||||
.foregroundColor(.blue)
|
||||
Text("Camera")
|
||||
.font(.headline)
|
||||
Spacer()
|
||||
Image(systemName: "chevron.right")
|
||||
.font(.caption)
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
.padding()
|
||||
.background(.regularMaterial)
|
||||
.cornerRadius(12)
|
||||
}
|
||||
.buttonStyle(PlainButtonStyle())
|
||||
}
|
||||
.padding(.horizontal)
|
||||
|
||||
Spacer()
|
||||
}
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .navigationBarTrailing) {
|
||||
Button("Cancel") {
|
||||
onDismiss()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.presentationDetents([.height(300)])
|
||||
.interactiveDismissDisabled(false)
|
||||
}
|
||||
}
|
||||
@@ -50,6 +50,8 @@ struct ContentView: View {
|
||||
Task {
|
||||
try? await Task.sleep(nanoseconds: 200_000_000) // 0.2 seconds
|
||||
dataManager.onAppBecomeActive()
|
||||
// Re-verify health integration when app becomes active
|
||||
await dataManager.healthKitService.verifyAndRestoreIntegration()
|
||||
}
|
||||
} else if newPhase == .background {
|
||||
dataManager.onAppEnterBackground()
|
||||
@@ -57,8 +59,12 @@ struct ContentView: View {
|
||||
}
|
||||
.onAppear {
|
||||
setupNotificationObservers()
|
||||
// Trigger auto-sync on app launch
|
||||
// Trigger auto-sync on app start only
|
||||
dataManager.syncService.triggerAutoSync(dataManager: dataManager)
|
||||
// Verify and restore health integration if it was previously enabled
|
||||
Task {
|
||||
await dataManager.healthKitService.verifyAndRestoreIntegration()
|
||||
}
|
||||
}
|
||||
.onDisappear {
|
||||
removeNotificationObservers()
|
||||
@@ -90,6 +96,8 @@ struct ContentView: View {
|
||||
// Small delay to ensure app is fully active
|
||||
try? await Task.sleep(nanoseconds: 800_000_000) // 0.8 seconds
|
||||
await dataManager.onAppBecomeActive()
|
||||
// Re-verify health integration when returning from background
|
||||
await dataManager.healthKitService.verifyAndRestoreIntegration()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -103,8 +111,8 @@ struct ContentView: View {
|
||||
Task {
|
||||
try? await Task.sleep(nanoseconds: 300_000_000) // 0.3 seconds
|
||||
await dataManager.onAppBecomeActive()
|
||||
// Trigger auto-sync when app becomes active
|
||||
await dataManager.syncService.triggerAutoSync(dataManager: dataManager)
|
||||
// Ensure health integration is verified
|
||||
await dataManager.healthKitService.verifyAndRestoreIntegration()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -8,5 +8,11 @@
|
||||
<true/>
|
||||
<key>NSPhotoLibraryUsageDescription</key>
|
||||
<string>This app needs access to your photo library to add photos to climbing problems.</string>
|
||||
<key>NSCameraUsageDescription</key>
|
||||
<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>
|
||||
</plist>
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import ActivityKit
|
||||
import Foundation
|
||||
|
||||
struct SessionActivityAttributes: ActivityAttributes {
|
||||
public struct ContentState: Codable, Hashable {
|
||||
struct SessionActivityAttributes: ActivityAttributes, Sendable {
|
||||
public struct ContentState: Codable, Hashable, Sendable {
|
||||
var elapsed: TimeInterval
|
||||
var totalAttempts: Int
|
||||
var completedProblems: Int
|
||||
|
||||
@@ -6,6 +6,12 @@ import Foundation
|
||||
// MARK: - Backup Format Specification v2.0
|
||||
|
||||
/// Root structure for OpenClimb backup data
|
||||
struct DeletedItem: Codable, Hashable {
|
||||
let id: String
|
||||
let type: String // "gym", "problem", "session", "attempt"
|
||||
let deletedAt: String
|
||||
}
|
||||
|
||||
struct ClimbDataBackup: Codable {
|
||||
let exportedAt: String
|
||||
let version: String
|
||||
@@ -14,6 +20,7 @@ struct ClimbDataBackup: Codable {
|
||||
let problems: [BackupProblem]
|
||||
let sessions: [BackupClimbSession]
|
||||
let attempts: [BackupAttempt]
|
||||
let deletedItems: [DeletedItem]
|
||||
|
||||
init(
|
||||
exportedAt: String,
|
||||
@@ -22,7 +29,8 @@ struct ClimbDataBackup: Codable {
|
||||
gyms: [BackupGym],
|
||||
problems: [BackupProblem],
|
||||
sessions: [BackupClimbSession],
|
||||
attempts: [BackupAttempt]
|
||||
attempts: [BackupAttempt],
|
||||
deletedItems: [DeletedItem] = []
|
||||
) {
|
||||
self.exportedAt = exportedAt
|
||||
self.version = version
|
||||
@@ -31,6 +39,7 @@ struct ClimbDataBackup: Codable {
|
||||
self.problems = problems
|
||||
self.sessions = sessions
|
||||
self.attempts = attempts
|
||||
self.deletedItems = deletedItems
|
||||
}
|
||||
}
|
||||
|
||||
@@ -389,10 +398,10 @@ struct BackupAttempt: Codable {
|
||||
formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
|
||||
|
||||
guard let uuid = UUID(uuidString: id),
|
||||
let sessionUuid = UUID(uuidString: sessionId),
|
||||
let problemUuid = UUID(uuidString: problemId),
|
||||
let timestampDate = formatter.date(from: timestamp),
|
||||
let createdDate = formatter.date(from: createdAt)
|
||||
let sessionUuid = UUID(uuidString: sessionId),
|
||||
let problemUuid = UUID(uuidString: problemId),
|
||||
let timestampDate = formatter.date(from: timestamp),
|
||||
let createdDate = formatter.date(from: createdAt)
|
||||
else {
|
||||
throw BackupError.invalidDateFormat
|
||||
}
|
||||
|
||||
@@ -6,5 +6,9 @@
|
||||
<array>
|
||||
<string>group.com.atridad.OpenClimb</string>
|
||||
</array>
|
||||
<key>com.apple.developer.healthkit</key>
|
||||
<true/>
|
||||
<key>com.apple.developer.healthkit.access</key>
|
||||
<array/>
|
||||
</dict>
|
||||
</plist>
|
||||
|
||||
236
ios/OpenClimb/Services/HealthKitService.swift
Normal 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"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -9,15 +9,20 @@ class SyncService: ObservableObject {
|
||||
@Published var syncError: String?
|
||||
@Published var isConnected = false
|
||||
@Published var isTesting = false
|
||||
@Published var isOfflineMode = false
|
||||
|
||||
private let userDefaults = UserDefaults.standard
|
||||
private var syncTask: Task<Void, Never>?
|
||||
private var pendingChanges = false
|
||||
private let syncDebounceDelay: TimeInterval = 2.0
|
||||
|
||||
private enum Keys {
|
||||
static let serverURL = "sync_server_url"
|
||||
static let authToken = "sync_auth_token"
|
||||
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 offlineMode = "offline_mode"
|
||||
}
|
||||
|
||||
var serverURL: String {
|
||||
@@ -43,7 +48,9 @@ class SyncService: ObservableObject {
|
||||
if let lastSync = userDefaults.object(forKey: Keys.lastSyncTime) as? Date {
|
||||
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 {
|
||||
@@ -144,6 +151,9 @@ class SyncService: ObservableObject {
|
||||
request.setValue("application/octet-stream", forHTTPHeaderField: "Content-Type")
|
||||
request.httpBody = imageData
|
||||
|
||||
request.timeoutInterval = 60.0
|
||||
request.cachePolicy = .reloadIgnoringLocalCacheData
|
||||
|
||||
let (_, response) = try await URLSession.shared.data(for: request)
|
||||
|
||||
guard let httpResponse = response as? HTTPURLResponse else {
|
||||
@@ -173,6 +183,9 @@ class SyncService: ObservableObject {
|
||||
request.httpMethod = "GET"
|
||||
request.setValue("Bearer \(authToken)", forHTTPHeaderField: "Authorization")
|
||||
|
||||
request.timeoutInterval = 45.0
|
||||
request.cachePolicy = .returnCacheDataElseLoad
|
||||
|
||||
let (data, response) = try await URLSession.shared.data(for: request)
|
||||
|
||||
guard let httpResponse = response as? HTTPURLResponse else {
|
||||
@@ -197,6 +210,11 @@ class SyncService: ObservableObject {
|
||||
}
|
||||
|
||||
func syncWithServer(dataManager: ClimbingDataManager) async throws {
|
||||
if isOfflineMode {
|
||||
print("Sync skipped: Offline mode is enabled.")
|
||||
return
|
||||
}
|
||||
|
||||
guard isConfigured else {
|
||||
throw SyncError.notConfigured
|
||||
}
|
||||
@@ -247,39 +265,13 @@ class SyncService: ObservableObject {
|
||||
try await syncImagesToServer(dataManager: dataManager)
|
||||
print("Initial upload completed")
|
||||
} else if hasLocalData && hasServerData {
|
||||
// Case 3: Both have data - compare timestamps (last writer wins)
|
||||
let localTimestamp = parseISO8601ToMillis(timestamp: localBackup.exportedAt)
|
||||
let serverTimestamp = parseISO8601ToMillis(timestamp: serverBackup.exportedAt)
|
||||
|
||||
print("DEBUG iOS Timestamp Comparison:")
|
||||
print(" Local exportedAt: '\(localBackup.exportedAt)' -> \(localTimestamp)")
|
||||
print(" Server exportedAt: '\(serverBackup.exportedAt)' -> \(serverTimestamp)")
|
||||
print(
|
||||
" DataStateManager last modified: '\(DataStateManager.shared.getLastModified())'"
|
||||
)
|
||||
print(" Comparison result: local=\(localTimestamp), server=\(serverTimestamp)")
|
||||
|
||||
if localTimestamp > serverTimestamp {
|
||||
// Local is newer - replace server with local data
|
||||
print("iOS SYNC: Case 3a - Local data is newer, replacing server content")
|
||||
let currentBackup = createBackupFromDataManager(dataManager)
|
||||
_ = try await uploadData(currentBackup)
|
||||
try await syncImagesToServer(dataManager: dataManager)
|
||||
print("Server replaced with local data")
|
||||
} else if serverTimestamp > localTimestamp {
|
||||
// Server is newer - replace local with server data
|
||||
print("iOS SYNC: Case 3b - Server data is newer, replacing local content")
|
||||
let imagePathMapping = try await syncImagesFromServer(
|
||||
backup: serverBackup, dataManager: dataManager)
|
||||
try importBackupToDataManager(
|
||||
serverBackup, dataManager: dataManager, imagePathMapping: imagePathMapping)
|
||||
print("Local data replaced with server data")
|
||||
} else {
|
||||
// Timestamps are equal - no sync needed
|
||||
print(
|
||||
"iOS SYNC: Case 3c - Data is in sync (timestamps equal), no action needed"
|
||||
)
|
||||
}
|
||||
// Case 3: Both have data - use safe merge strategy
|
||||
print("iOS SYNC: Case 3 - Merging local and server data safely")
|
||||
try await mergeDataSafely(
|
||||
localBackup: localBackup,
|
||||
serverBackup: serverBackup,
|
||||
dataManager: dataManager)
|
||||
print("Safe merge completed")
|
||||
} else {
|
||||
print("No data to sync")
|
||||
}
|
||||
@@ -309,7 +301,6 @@ class SyncService: ObservableObject {
|
||||
{
|
||||
var imagePathMapping: [String: String] = [:]
|
||||
|
||||
// Process images by problem to maintain consistent naming
|
||||
for problem in backup.problems {
|
||||
guard let imagePaths = problem.imagePaths, !imagePaths.isEmpty else { continue }
|
||||
|
||||
@@ -319,19 +310,13 @@ class SyncService: ObservableObject {
|
||||
do {
|
||||
let imageData = try await downloadImage(filename: serverFilename)
|
||||
|
||||
// Generate consistent filename if needed
|
||||
let consistentFilename =
|
||||
ImageNamingUtils.isValidImageFilename(serverFilename)
|
||||
? serverFilename
|
||||
: ImageNamingUtils.generateImageFilename(
|
||||
problemId: problem.id, imageIndex: index)
|
||||
let consistentFilename = ImageNamingUtils.generateImageFilename(
|
||||
problemId: problem.id, imageIndex: index)
|
||||
|
||||
// Save image with consistent filename
|
||||
let imageManager = ImageManager.shared
|
||||
_ = try imageManager.saveImportedImage(
|
||||
imageData, filename: consistentFilename)
|
||||
|
||||
// Map server filename to consistent local filename
|
||||
imagePathMapping[serverFilename] = consistentFilename
|
||||
print("Downloaded and mapped image: \(serverFilename) -> \(consistentFilename)")
|
||||
} catch SyncError.imageNotFound {
|
||||
@@ -355,12 +340,8 @@ class SyncService: ObservableObject {
|
||||
for (index, imagePath) in problem.imagePaths.enumerated() {
|
||||
let filename = URL(fileURLWithPath: imagePath).lastPathComponent
|
||||
|
||||
// Ensure filename follows consistent naming convention
|
||||
let consistentFilename =
|
||||
ImageNamingUtils.isValidImageFilename(filename)
|
||||
? filename
|
||||
: ImageNamingUtils.generateImageFilename(
|
||||
problemId: problem.id.uuidString, imageIndex: index)
|
||||
let consistentFilename = ImageNamingUtils.generateImageFilename(
|
||||
problemId: problem.id.uuidString, imageIndex: index)
|
||||
|
||||
// Load image data
|
||||
let imageManager = ImageManager.shared
|
||||
@@ -378,7 +359,6 @@ class SyncService: ObservableObject {
|
||||
print("Renamed local image: \(filename) -> \(consistentFilename)")
|
||||
|
||||
// Update problem's image path in memory for consistency
|
||||
// Note: This would require updating the problem in the data manager
|
||||
} catch {
|
||||
print("Failed to rename local image, using original: \(error)")
|
||||
}
|
||||
@@ -397,20 +377,171 @@ class SyncService: ObservableObject {
|
||||
|
||||
private func createBackupFromDataManager(_ 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)
|
||||
}
|
||||
|
||||
print(
|
||||
"iOS SYNC: Excluding \(dataManager.sessions.count - completedSessions.count) active sessions and \(dataManager.attempts.count - completedAttempts.count) active session attempts from sync"
|
||||
)
|
||||
|
||||
return ClimbDataBackup(
|
||||
exportedAt: DataStateManager.shared.getLastModified(),
|
||||
gyms: dataManager.gyms.map { BackupGym(from: $0) },
|
||||
problems: dataManager.problems.map { BackupProblem(from: $0) },
|
||||
sessions: dataManager.sessions.map { BackupClimbSession(from: $0) },
|
||||
attempts: dataManager.attempts.map { BackupAttempt(from: $0) }
|
||||
sessions: completedSessions.map { BackupClimbSession(from: $0) },
|
||||
attempts: completedAttempts.map { BackupAttempt(from: $0) },
|
||||
deletedItems: dataManager.getDeletedItems()
|
||||
)
|
||||
}
|
||||
|
||||
func createBackupForExport(_ dataManager: ClimbingDataManager) -> ClimbDataBackup {
|
||||
// Filter out active sessions and their attempts from sync
|
||||
let completedSessions = dataManager.sessions.filter { $0.status != .active }
|
||||
let activeSessionIds = Set(
|
||||
dataManager.sessions.filter { $0.status == .active }.map { $0.id })
|
||||
let completedAttempts = dataManager.attempts.filter {
|
||||
!activeSessionIds.contains($0.sessionId)
|
||||
}
|
||||
|
||||
// Create backup with normalized image paths for export
|
||||
return ClimbDataBackup(
|
||||
exportedAt: DataStateManager.shared.getLastModified(),
|
||||
gyms: dataManager.gyms.map { BackupGym(from: $0) },
|
||||
problems: dataManager.problems.map { problem in
|
||||
var backupProblem = BackupProblem(from: problem)
|
||||
|
||||
if !problem.imagePaths.isEmpty {
|
||||
let normalizedPaths = problem.imagePaths.enumerated().map { index, _ in
|
||||
ImageNamingUtils.generateImageFilename(
|
||||
problemId: problem.id.uuidString, imageIndex: index)
|
||||
}
|
||||
|
||||
backupProblem = BackupProblem(
|
||||
id: backupProblem.id,
|
||||
gymId: backupProblem.gymId,
|
||||
name: backupProblem.name,
|
||||
description: backupProblem.description,
|
||||
climbType: backupProblem.climbType,
|
||||
difficulty: backupProblem.difficulty,
|
||||
tags: backupProblem.tags,
|
||||
location: backupProblem.location,
|
||||
imagePaths: normalizedPaths,
|
||||
isActive: backupProblem.isActive,
|
||||
dateSet: backupProblem.dateSet,
|
||||
notes: backupProblem.notes,
|
||||
createdAt: backupProblem.createdAt,
|
||||
updatedAt: backupProblem.updatedAt
|
||||
)
|
||||
}
|
||||
return backupProblem
|
||||
},
|
||||
sessions: completedSessions.map { BackupClimbSession(from: $0) },
|
||||
attempts: completedAttempts.map { BackupAttempt(from: $0) },
|
||||
deletedItems: dataManager.getDeletedItems()
|
||||
)
|
||||
}
|
||||
|
||||
private func mergeDataSafely(
|
||||
localBackup: ClimbDataBackup,
|
||||
serverBackup: ClimbDataBackup,
|
||||
dataManager: ClimbingDataManager
|
||||
) async throws {
|
||||
// Download server images first
|
||||
let imagePathMapping = try await syncImagesFromServer(
|
||||
backup: serverBackup, dataManager: dataManager)
|
||||
|
||||
// Merge deletion lists first to prevent resurrection of deleted items
|
||||
let localDeletions = dataManager.getDeletedItems()
|
||||
let allDeletions = localDeletions + serverBackup.deletedItems
|
||||
let uniqueDeletions = Array(Set(allDeletions))
|
||||
|
||||
print("Merging gyms...")
|
||||
let mergedGyms = mergeGyms(
|
||||
local: dataManager.gyms,
|
||||
server: serverBackup.gyms,
|
||||
deletedItems: uniqueDeletions)
|
||||
|
||||
print("Merging problems...")
|
||||
let mergedProblems = try mergeProblems(
|
||||
local: dataManager.problems,
|
||||
server: serverBackup.problems,
|
||||
imagePathMapping: imagePathMapping,
|
||||
deletedItems: uniqueDeletions)
|
||||
|
||||
print("Merging sessions...")
|
||||
let mergedSessions = try mergeSessions(
|
||||
local: dataManager.sessions,
|
||||
server: serverBackup.sessions,
|
||||
deletedItems: uniqueDeletions)
|
||||
|
||||
print("Merging attempts...")
|
||||
let mergedAttempts = try mergeAttempts(
|
||||
local: dataManager.attempts,
|
||||
server: serverBackup.attempts,
|
||||
deletedItems: uniqueDeletions)
|
||||
|
||||
// Update data manager with merged data
|
||||
dataManager.gyms = mergedGyms
|
||||
dataManager.problems = mergedProblems
|
||||
dataManager.sessions = mergedSessions
|
||||
dataManager.attempts = mergedAttempts
|
||||
|
||||
// Save all data
|
||||
dataManager.saveGyms()
|
||||
dataManager.saveProblems()
|
||||
dataManager.saveSessions()
|
||||
dataManager.saveAttempts()
|
||||
dataManager.saveActiveSession()
|
||||
|
||||
// Update local deletions with merged list
|
||||
dataManager.clearDeletedItems()
|
||||
if let data = try? JSONEncoder().encode(uniqueDeletions) {
|
||||
UserDefaults.standard.set(data, forKey: "openclimb_deleted_items")
|
||||
}
|
||||
|
||||
// Upload merged data back to server
|
||||
let mergedBackup = createBackupFromDataManager(dataManager)
|
||||
_ = try await uploadData(mergedBackup)
|
||||
try await syncImagesToServer(dataManager: dataManager)
|
||||
|
||||
// Update timestamp
|
||||
DataStateManager.shared.updateDataState()
|
||||
}
|
||||
|
||||
private func importBackupToDataManager(
|
||||
_ backup: ClimbDataBackup, dataManager: ClimbingDataManager,
|
||||
imagePathMapping: [String: String] = [:]
|
||||
) throws {
|
||||
do {
|
||||
// Store active sessions and their attempts before import (but exclude any that were deleted)
|
||||
let localDeletedItems = dataManager.getDeletedItems()
|
||||
let allDeletedSessionIds = Set(
|
||||
(backup.deletedItems + localDeletedItems)
|
||||
.filter { $0.type == "session" }
|
||||
.map { $0.id }
|
||||
)
|
||||
let activeSessions = dataManager.sessions.filter {
|
||||
$0.status == .active && !allDeletedSessionIds.contains($0.id.uuidString)
|
||||
}
|
||||
let activeSessionIds = Set(activeSessions.map { $0.id })
|
||||
let allDeletedAttemptIds = Set(
|
||||
(backup.deletedItems + localDeletedItems)
|
||||
.filter { $0.type == "attempt" }
|
||||
.map { $0.id }
|
||||
)
|
||||
let activeAttempts = dataManager.attempts.filter {
|
||||
activeSessionIds.contains($0.sessionId)
|
||||
&& !allDeletedAttemptIds.contains($0.id.uuidString)
|
||||
}
|
||||
|
||||
print(
|
||||
"iOS IMPORT: Preserving \(activeSessions.count) active sessions and \(activeAttempts.count) active attempts during import"
|
||||
)
|
||||
|
||||
// Update problem image paths to point to downloaded images
|
||||
let updatedBackup: ClimbDataBackup
|
||||
@@ -436,18 +567,58 @@ class SyncService: ObservableObject {
|
||||
updatedAt: problem.updatedAt
|
||||
)
|
||||
}
|
||||
// Filter out deleted items before creating updated backup
|
||||
let deletedGymIds = Set(
|
||||
backup.deletedItems.filter { $0.type == "gym" }.map { $0.id })
|
||||
let deletedProblemIds = Set(
|
||||
backup.deletedItems.filter { $0.type == "problem" }.map { $0.id })
|
||||
let deletedSessionIds = Set(
|
||||
backup.deletedItems.filter { $0.type == "session" }.map { $0.id })
|
||||
let deletedAttemptIds = Set(
|
||||
backup.deletedItems.filter { $0.type == "attempt" }.map { $0.id })
|
||||
|
||||
let filteredGyms = backup.gyms.filter { !deletedGymIds.contains($0.id) }
|
||||
let filteredProblems = updatedProblems.filter { !deletedProblemIds.contains($0.id) }
|
||||
let filteredSessions = backup.sessions.filter { !deletedSessionIds.contains($0.id) }
|
||||
let filteredAttempts = backup.attempts.filter { !deletedAttemptIds.contains($0.id) }
|
||||
|
||||
updatedBackup = ClimbDataBackup(
|
||||
exportedAt: backup.exportedAt,
|
||||
version: backup.version,
|
||||
formatVersion: backup.formatVersion,
|
||||
gyms: backup.gyms,
|
||||
problems: updatedProblems,
|
||||
sessions: backup.sessions,
|
||||
attempts: backup.attempts
|
||||
gyms: filteredGyms,
|
||||
problems: filteredProblems,
|
||||
sessions: filteredSessions,
|
||||
attempts: filteredAttempts,
|
||||
deletedItems: backup.deletedItems
|
||||
)
|
||||
|
||||
} else {
|
||||
updatedBackup = backup
|
||||
// Filter out deleted items even when no image path mapping
|
||||
let deletedGymIds = Set(
|
||||
backup.deletedItems.filter { $0.type == "gym" }.map { $0.id })
|
||||
let deletedProblemIds = Set(
|
||||
backup.deletedItems.filter { $0.type == "problem" }.map { $0.id })
|
||||
let deletedSessionIds = Set(
|
||||
backup.deletedItems.filter { $0.type == "session" }.map { $0.id })
|
||||
let deletedAttemptIds = Set(
|
||||
backup.deletedItems.filter { $0.type == "attempt" }.map { $0.id })
|
||||
|
||||
let filteredGyms = backup.gyms.filter { !deletedGymIds.contains($0.id) }
|
||||
let filteredProblems = backup.problems.filter { !deletedProblemIds.contains($0.id) }
|
||||
let filteredSessions = backup.sessions.filter { !deletedSessionIds.contains($0.id) }
|
||||
let filteredAttempts = backup.attempts.filter { !deletedAttemptIds.contains($0.id) }
|
||||
|
||||
updatedBackup = ClimbDataBackup(
|
||||
exportedAt: backup.exportedAt,
|
||||
version: backup.version,
|
||||
formatVersion: backup.formatVersion,
|
||||
gyms: filteredGyms,
|
||||
problems: filteredProblems,
|
||||
sessions: filteredSessions,
|
||||
attempts: filteredAttempts,
|
||||
deletedItems: backup.deletedItems
|
||||
)
|
||||
}
|
||||
|
||||
// Create a minimal ZIP with just the JSON data for existing import mechanism
|
||||
@@ -456,12 +627,36 @@ class SyncService: ObservableObject {
|
||||
// Use existing import method which properly handles data restoration
|
||||
try dataManager.importData(from: zipData, showSuccessMessage: false)
|
||||
|
||||
// Restore active sessions and their attempts after import
|
||||
for session in activeSessions {
|
||||
print("iOS IMPORT: Restoring active session: \(session.id)")
|
||||
dataManager.sessions.append(session)
|
||||
if session.id == dataManager.activeSession?.id {
|
||||
dataManager.activeSession = session
|
||||
}
|
||||
}
|
||||
|
||||
for attempt in activeAttempts {
|
||||
dataManager.attempts.append(attempt)
|
||||
}
|
||||
|
||||
// Save restored data
|
||||
dataManager.saveSessions()
|
||||
dataManager.saveAttempts()
|
||||
dataManager.saveActiveSession()
|
||||
|
||||
// Import deletion records to prevent future resurrections
|
||||
dataManager.clearDeletedItems()
|
||||
if let data = try? JSONEncoder().encode(backup.deletedItems) {
|
||||
UserDefaults.standard.set(data, forKey: "openclimb_deleted_items")
|
||||
print("iOS IMPORT: Imported \(backup.deletedItems.count) deletion records")
|
||||
}
|
||||
|
||||
// Update local data state to match imported data timestamp
|
||||
DataStateManager.shared.setLastModified(backup.exportedAt)
|
||||
print("Data state synchronized to imported timestamp: \(backup.exportedAt)")
|
||||
|
||||
} catch {
|
||||
|
||||
throw SyncError.importFailed(error)
|
||||
}
|
||||
}
|
||||
@@ -479,17 +674,31 @@ class SyncService: ObservableObject {
|
||||
}
|
||||
let jsonData = try encoder.encode(backup)
|
||||
|
||||
// Collect all downloaded images from ImageManager
|
||||
// Collect all images from ImageManager
|
||||
let imageManager = ImageManager.shared
|
||||
var imageFiles: [(filename: String, data: Data)] = []
|
||||
let imagePaths = Set(backup.problems.flatMap { $0.imagePaths ?? [] })
|
||||
|
||||
for imagePath in imagePaths {
|
||||
let filename = URL(fileURLWithPath: imagePath).lastPathComponent
|
||||
let fullPath = imageManager.imagesDirectory.appendingPathComponent(filename).path
|
||||
if let imageData = imageManager.loadImageData(fromPath: fullPath) {
|
||||
imageFiles.append((filename: filename, data: imageData))
|
||||
// Get original problems to access actual image paths on disk
|
||||
if let problemsData = userDefaults.data(forKey: "problems"),
|
||||
let problems = try? JSONDecoder().decode([Problem].self, from: problemsData)
|
||||
{
|
||||
// Create a mapping from normalized paths to actual paths
|
||||
for problem in problems {
|
||||
for (index, imagePath) in problem.imagePaths.enumerated() {
|
||||
// Get the actual filename on disk
|
||||
let actualFilename = URL(fileURLWithPath: imagePath).lastPathComponent
|
||||
let fullPath = imageManager.imagesDirectory.appendingPathComponent(
|
||||
actualFilename
|
||||
).path
|
||||
|
||||
// Generate the normalized filename for the ZIP
|
||||
let normalizedFilename = ImageNamingUtils.generateImageFilename(
|
||||
problemId: problem.id.uuidString, imageIndex: index)
|
||||
|
||||
if let imageData = imageManager.loadImageData(fromPath: fullPath) {
|
||||
imageFiles.append((filename: normalizedFilename, data: imageData))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -734,20 +943,51 @@ class SyncService: ObservableObject {
|
||||
}
|
||||
|
||||
func triggerAutoSync(dataManager: ClimbingDataManager) {
|
||||
// Early exit if sync cannot proceed - don't set isSyncing
|
||||
guard isConnected && isConfigured && isAutoSyncEnabled else {
|
||||
// Ensure isSyncing is false when sync is not possible
|
||||
if isSyncing {
|
||||
isSyncing = false
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// Prevent multiple simultaneous syncs
|
||||
guard !isSyncing else {
|
||||
if isSyncing {
|
||||
pendingChanges = true
|
||||
return
|
||||
}
|
||||
|
||||
syncTask?.cancel()
|
||||
|
||||
syncTask = Task {
|
||||
try? await Task.sleep(nanoseconds: UInt64(syncDebounceDelay * 1_000_000_000))
|
||||
|
||||
guard !Task.isCancelled else { return }
|
||||
|
||||
repeat {
|
||||
pendingChanges = false
|
||||
|
||||
do {
|
||||
try await syncWithServer(dataManager: dataManager)
|
||||
} catch {
|
||||
await MainActor.run {
|
||||
self.isSyncing = false
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if pendingChanges {
|
||||
try? await Task.sleep(nanoseconds: UInt64(syncDebounceDelay * 1_000_000_000))
|
||||
}
|
||||
} while pendingChanges && !Task.isCancelled
|
||||
}
|
||||
}
|
||||
|
||||
func forceSyncNow(dataManager: ClimbingDataManager) {
|
||||
guard isConnected && isConfigured else { return }
|
||||
|
||||
syncTask?.cancel()
|
||||
syncTask = nil
|
||||
pendingChanges = false
|
||||
|
||||
Task {
|
||||
do {
|
||||
try await syncWithServer(dataManager: dataManager)
|
||||
@@ -760,6 +1000,10 @@ class SyncService: ObservableObject {
|
||||
}
|
||||
|
||||
func disconnect() {
|
||||
syncTask?.cancel()
|
||||
syncTask = nil
|
||||
pendingChanges = false
|
||||
isSyncing = false
|
||||
isConnected = false
|
||||
lastSyncTime = nil
|
||||
syncError = nil
|
||||
@@ -776,6 +1020,162 @@ class SyncService: ObservableObject {
|
||||
userDefaults.removeObject(forKey: Keys.lastSyncTime)
|
||||
userDefaults.removeObject(forKey: Keys.isConnected)
|
||||
userDefaults.removeObject(forKey: Keys.autoSyncEnabled)
|
||||
syncTask?.cancel()
|
||||
syncTask = nil
|
||||
pendingChanges = false
|
||||
}
|
||||
|
||||
deinit {
|
||||
syncTask?.cancel()
|
||||
}
|
||||
|
||||
// MARK: - Merging
|
||||
// MARK: - Safe Merge Functions
|
||||
|
||||
private func mergeGyms(local: [Gym], server: [BackupGym], deletedItems: [DeletedItem]) -> [Gym]
|
||||
{
|
||||
var merged = local
|
||||
let deletedGymIds = Set(deletedItems.filter { $0.type == "gym" }.map { $0.id })
|
||||
|
||||
let localGymIds = Set(local.map { $0.id.uuidString })
|
||||
|
||||
merged.removeAll { deletedGymIds.contains($0.id.uuidString) }
|
||||
|
||||
// Add new items from server (excluding deleted ones)
|
||||
for serverGym in server {
|
||||
if let serverGymConverted = try? serverGym.toGym() {
|
||||
let localHasGym = localGymIds.contains(serverGym.id)
|
||||
let isDeleted = deletedGymIds.contains(serverGym.id)
|
||||
|
||||
if !localHasGym && !isDeleted {
|
||||
merged.append(serverGymConverted)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return merged
|
||||
}
|
||||
|
||||
private func mergeProblems(
|
||||
local: [Problem],
|
||||
server: [BackupProblem],
|
||||
imagePathMapping: [String: String],
|
||||
deletedItems: [DeletedItem]
|
||||
) throws -> [Problem] {
|
||||
var merged = local
|
||||
let deletedProblemIds = Set(deletedItems.filter { $0.type == "problem" }.map { $0.id })
|
||||
|
||||
let localProblemIds = Set(local.map { $0.id.uuidString })
|
||||
|
||||
merged.removeAll { deletedProblemIds.contains($0.id.uuidString) }
|
||||
|
||||
for serverProblem in server {
|
||||
let localHasProblem = localProblemIds.contains(serverProblem.id)
|
||||
let isDeleted = deletedProblemIds.contains(serverProblem.id)
|
||||
|
||||
if !localHasProblem && !isDeleted {
|
||||
var problemToAdd = serverProblem
|
||||
|
||||
if !imagePathMapping.isEmpty, let imagePaths = serverProblem.imagePaths,
|
||||
!imagePaths.isEmpty
|
||||
{
|
||||
let updatedImagePaths = imagePaths.compactMap { oldPath in
|
||||
imagePathMapping[oldPath] ?? oldPath
|
||||
}
|
||||
if updatedImagePaths != imagePaths {
|
||||
problemToAdd = BackupProblem(
|
||||
id: serverProblem.id,
|
||||
gymId: serverProblem.gymId,
|
||||
name: serverProblem.name,
|
||||
description: serverProblem.description,
|
||||
climbType: serverProblem.climbType,
|
||||
difficulty: serverProblem.difficulty,
|
||||
tags: serverProblem.tags,
|
||||
location: serverProblem.location,
|
||||
imagePaths: updatedImagePaths,
|
||||
isActive: serverProblem.isActive,
|
||||
dateSet: serverProblem.dateSet,
|
||||
notes: serverProblem.notes,
|
||||
createdAt: serverProblem.createdAt,
|
||||
updatedAt: serverProblem.updatedAt
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
if let serverProblemConverted = try? problemToAdd.toProblem() {
|
||||
merged.append(serverProblemConverted)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return merged
|
||||
}
|
||||
|
||||
private func mergeSessions(
|
||||
local: [ClimbSession], server: [BackupClimbSession], deletedItems: [DeletedItem]
|
||||
) throws
|
||||
-> [ClimbSession]
|
||||
{
|
||||
var merged = local
|
||||
let deletedSessionIds = Set(deletedItems.filter { $0.type == "session" }.map { $0.id })
|
||||
|
||||
let localSessionIds = Set(local.map { $0.id.uuidString })
|
||||
|
||||
merged.removeAll { session in
|
||||
deletedSessionIds.contains(session.id.uuidString) && session.status != .active
|
||||
}
|
||||
|
||||
for serverSession in server {
|
||||
let localHasSession = localSessionIds.contains(serverSession.id)
|
||||
let isDeleted = deletedSessionIds.contains(serverSession.id)
|
||||
|
||||
if !localHasSession && !isDeleted {
|
||||
if let serverSessionConverted = try? serverSession.toClimbSession() {
|
||||
merged.append(serverSessionConverted)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return merged
|
||||
}
|
||||
|
||||
private func mergeAttempts(
|
||||
local: [Attempt], server: [BackupAttempt], deletedItems: [DeletedItem]
|
||||
) throws -> [Attempt] {
|
||||
var merged = local
|
||||
let deletedAttemptIds = Set(deletedItems.filter { $0.type == "attempt" }.map { $0.id })
|
||||
|
||||
let localAttemptIds = Set(local.map { $0.id.uuidString })
|
||||
|
||||
// Get active session IDs to protect their attempts
|
||||
let activeSessionIds = Set(
|
||||
local.compactMap { attempt in
|
||||
// This is a simplified check - in a real implementation you'd want to cross-reference with sessions
|
||||
return attempt.sessionId
|
||||
}.filter { sessionId in
|
||||
// Check if this session ID belongs to an active session
|
||||
// For now, we'll be conservative and not delete attempts during merge
|
||||
return true
|
||||
})
|
||||
|
||||
// Remove items that were deleted on other devices (but be conservative with attempts)
|
||||
merged.removeAll { attempt in
|
||||
deletedAttemptIds.contains(attempt.id.uuidString)
|
||||
&& !activeSessionIds.contains(attempt.sessionId)
|
||||
}
|
||||
|
||||
for serverAttempt in server {
|
||||
let localHasAttempt = localAttemptIds.contains(serverAttempt.id)
|
||||
let isDeleted = deletedAttemptIds.contains(serverAttempt.id)
|
||||
|
||||
if !localHasAttempt && !isDeleted {
|
||||
if let serverAttemptConverted = try? serverAttempt.toAttempt() {
|
||||
merged.append(serverAttemptConverted)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return merged
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -106,7 +106,7 @@ import SwiftUI
|
||||
.font(.subheadline)
|
||||
.fontWeight(.medium)
|
||||
|
||||
Image("MountainsIcon")
|
||||
Image("AppLogo")
|
||||
.resizable()
|
||||
.frame(width: 24, height: 24)
|
||||
.background(Circle().fill(.quaternary))
|
||||
@@ -115,7 +115,7 @@ import SwiftUI
|
||||
.font(.caption)
|
||||
.foregroundColor(.secondary)
|
||||
|
||||
Image("MountainsIcon")
|
||||
Image("AppLogo")
|
||||
.resizable()
|
||||
.frame(width: 32, height: 32)
|
||||
.background(Circle().fill(.quaternary))
|
||||
@@ -322,7 +322,7 @@ import SwiftUI
|
||||
// Check if main bundle contains the expected icon assets
|
||||
let expectedAssets = [
|
||||
"AppIcon",
|
||||
"MountainsIcon",
|
||||
"AppLogo",
|
||||
]
|
||||
|
||||
for asset in expectedAssets {
|
||||
@@ -376,7 +376,7 @@ import SwiftUI
|
||||
.font(.headline)
|
||||
|
||||
HStack(spacing: 20) {
|
||||
Image("MountainsIcon")
|
||||
Image("AppLogo")
|
||||
.resizable()
|
||||
.frame(width: 64, height: 64)
|
||||
.background(
|
||||
@@ -385,7 +385,7 @@ import SwiftUI
|
||||
)
|
||||
|
||||
VStack(alignment: .leading) {
|
||||
Text("MountainsIcon")
|
||||
Text("AppLogo")
|
||||
.font(.subheadline)
|
||||
.fontWeight(.medium)
|
||||
Text("In-app icon display")
|
||||
|
||||
@@ -1,9 +1,12 @@
|
||||
import Foundation
|
||||
import ImageIO
|
||||
import SwiftUI
|
||||
import UIKit
|
||||
|
||||
class ImageManager {
|
||||
static let shared = ImageManager()
|
||||
|
||||
private let thumbnailCache = NSCache<NSString, UIImage>()
|
||||
private let fileManager = FileManager.default
|
||||
private let appSupportDirectoryName = "OpenClimb"
|
||||
private let imagesDirectoryName = "Images"
|
||||
@@ -478,6 +481,51 @@ class ImageManager {
|
||||
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 {
|
||||
let primaryPath = getFullPath(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)")
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -11,21 +11,18 @@ class ImageNamingUtils {
|
||||
private static let hashLength = 12
|
||||
|
||||
/// Generates a deterministic filename for a problem image
|
||||
static func generateImageFilename(problemId: String, timestamp: String, imageIndex: Int)
|
||||
-> String
|
||||
{
|
||||
|
||||
let input = "\(problemId)_\(timestamp)_\(imageIndex)"
|
||||
static func generateImageFilename(problemId: String, imageIndex: Int) -> String {
|
||||
let input = "\(problemId)_\(imageIndex)"
|
||||
let hash = createHash(from: input)
|
||||
|
||||
return "problem_\(hash)_\(imageIndex)\(imageExtension)"
|
||||
}
|
||||
|
||||
/// Generates a deterministic filename using current timestamp
|
||||
static func generateImageFilename(problemId: String, imageIndex: Int) -> String {
|
||||
let timestamp = ISO8601DateFormatter().string(from: Date())
|
||||
return generateImageFilename(
|
||||
problemId: problemId, timestamp: timestamp, imageIndex: imageIndex)
|
||||
/// Legacy method for backward compatibility
|
||||
static func generateImageFilename(problemId: String, timestamp: String, imageIndex: Int)
|
||||
-> String
|
||||
{
|
||||
return generateImageFilename(problemId: problemId, imageIndex: imageIndex)
|
||||
}
|
||||
|
||||
/// Extracts problem ID from an image filename
|
||||
@@ -64,9 +61,7 @@ class ImageNamingUtils {
|
||||
return oldFilename
|
||||
}
|
||||
|
||||
let timestamp = ISO8601DateFormatter().string(from: Date())
|
||||
return generateImageFilename(
|
||||
problemId: problemId, timestamp: timestamp, imageIndex: imageIndex)
|
||||
return generateImageFilename(problemId: problemId, imageIndex: imageIndex)
|
||||
}
|
||||
|
||||
/// Creates a deterministic hash from input string
|
||||
@@ -84,8 +79,7 @@ class ImageNamingUtils {
|
||||
var renameMap: [String: String] = [:]
|
||||
|
||||
for (index, oldFilename) in existingFilenames.enumerated() {
|
||||
let newFilename = migrateFilename(
|
||||
oldFilename: oldFilename, problemId: problemId, imageIndex: index)
|
||||
let newFilename = generateImageFilename(problemId: problemId, imageIndex: index)
|
||||
if newFilename != oldFilename {
|
||||
renameMap[oldFilename] = newFilename
|
||||
}
|
||||
@@ -113,6 +107,40 @@ class ImageNamingUtils {
|
||||
invalidImages: invalidImages
|
||||
)
|
||||
}
|
||||
|
||||
/// Generates the canonical filename that should be used for a problem image
|
||||
static func getCanonicalImageFilename(problemId: String, imageIndex: Int) -> String {
|
||||
return generateImageFilename(problemId: problemId, imageIndex: imageIndex)
|
||||
}
|
||||
|
||||
/// Creates a mapping of existing server filenames to canonical filenames
|
||||
static func createServerMigrationMap(
|
||||
problemId: String,
|
||||
serverImageFilenames: [String],
|
||||
localImageCount: Int
|
||||
) -> [String: String] {
|
||||
var migrationMap: [String: String] = [:]
|
||||
|
||||
for imageIndex in 0..<localImageCount {
|
||||
let canonicalName = getCanonicalImageFilename(
|
||||
problemId: problemId, imageIndex: imageIndex)
|
||||
|
||||
if serverImageFilenames.contains(canonicalName) {
|
||||
continue
|
||||
}
|
||||
|
||||
for serverFilename in serverImageFilenames {
|
||||
if isValidImageFilename(serverFilename)
|
||||
&& !migrationMap.values.contains(serverFilename)
|
||||
{
|
||||
migrationMap[serverFilename] = canonicalName
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return migrationMap
|
||||
}
|
||||
}
|
||||
|
||||
// Result of image filename validation
|
||||
|
||||
147
ios/OpenClimb/Utils/OrientationAwareImage.swift
Normal 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)
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import Combine
|
||||
import Foundation
|
||||
import HealthKit
|
||||
import SwiftUI
|
||||
import UniformTypeIdentifiers
|
||||
|
||||
@@ -27,12 +28,12 @@ class ClimbingDataManager: ObservableObject {
|
||||
private let sharedUserDefaults = UserDefaults(suiteName: "group.com.atridad.OpenClimb")
|
||||
private let encoder = JSONEncoder()
|
||||
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 healthKitService = HealthKitService.shared
|
||||
|
||||
// Published property to propagate sync state changes
|
||||
@Published var isSyncing = false
|
||||
|
||||
private enum Keys {
|
||||
@@ -41,6 +42,7 @@ class ClimbingDataManager: ObservableObject {
|
||||
static let sessions = "openclimb_sessions"
|
||||
static let attempts = "openclimb_attempts"
|
||||
static let activeSession = "openclimb_active_session"
|
||||
static let deletedItems = "openclimb_deleted_items"
|
||||
}
|
||||
|
||||
// Widget data models
|
||||
@@ -67,8 +69,8 @@ class ClimbingDataManager: ObservableObject {
|
||||
init() {
|
||||
_ = ImageManager.shared
|
||||
loadAllData()
|
||||
migrateImagePaths()
|
||||
setupLiveActivityNotifications()
|
||||
setupMigrationNotifications()
|
||||
|
||||
// Keep our published isSyncing in sync with syncService.isSyncing
|
||||
syncService.$isSyncing
|
||||
@@ -87,6 +89,9 @@ class ClimbingDataManager: ObservableObject {
|
||||
if let observer = liveActivityObserver {
|
||||
NotificationCenter.default.removeObserver(observer)
|
||||
}
|
||||
if let observer = migrationObserver {
|
||||
NotificationCenter.default.removeObserver(observer)
|
||||
}
|
||||
}
|
||||
|
||||
private func loadAllData() {
|
||||
@@ -95,6 +100,9 @@ class ClimbingDataManager: ObservableObject {
|
||||
loadSessions()
|
||||
loadAttempts()
|
||||
loadActiveSession()
|
||||
|
||||
// Clean up orphaned data after loading
|
||||
cleanupOrphanedData()
|
||||
}
|
||||
|
||||
private func loadGyms() {
|
||||
@@ -137,7 +145,7 @@ class ClimbingDataManager: ObservableObject {
|
||||
}
|
||||
}
|
||||
|
||||
private func saveGyms() {
|
||||
internal func saveGyms() {
|
||||
if let data = try? encoder.encode(gyms) {
|
||||
userDefaults.set(data, forKey: Keys.gyms)
|
||||
// Share with widget - convert to widget format
|
||||
@@ -150,7 +158,7 @@ class ClimbingDataManager: ObservableObject {
|
||||
}
|
||||
}
|
||||
|
||||
private func saveProblems() {
|
||||
internal func saveProblems() {
|
||||
if let data = try? encoder.encode(problems) {
|
||||
userDefaults.set(data, forKey: Keys.problems)
|
||||
// Share with widget
|
||||
@@ -158,7 +166,7 @@ class ClimbingDataManager: ObservableObject {
|
||||
}
|
||||
}
|
||||
|
||||
private func saveSessions() {
|
||||
internal func saveSessions() {
|
||||
if let data = try? encoder.encode(sessions) {
|
||||
userDefaults.set(data, forKey: Keys.sessions)
|
||||
// Share with widget - convert to widget format
|
||||
@@ -176,7 +184,7 @@ class ClimbingDataManager: ObservableObject {
|
||||
}
|
||||
}
|
||||
|
||||
private func saveAttempts() {
|
||||
internal func saveAttempts() {
|
||||
if let data = try? encoder.encode(attempts) {
|
||||
userDefaults.set(data, forKey: Keys.attempts)
|
||||
// Share with widget - convert to widget format
|
||||
@@ -197,7 +205,7 @@ class ClimbingDataManager: ObservableObject {
|
||||
}
|
||||
}
|
||||
|
||||
private func saveActiveSession() {
|
||||
internal func saveActiveSession() {
|
||||
if let activeSession = activeSession,
|
||||
let data = try? encoder.encode(activeSession)
|
||||
{
|
||||
@@ -246,6 +254,7 @@ class ClimbingDataManager: ObservableObject {
|
||||
|
||||
// Delete the gym
|
||||
gyms.removeAll { $0.id == gym.id }
|
||||
trackDeletion(itemId: gym.id.uuidString, itemType: "gym")
|
||||
saveGyms()
|
||||
DataStateManager.shared.updateDataState()
|
||||
successMessage = "Gym deleted successfully"
|
||||
@@ -284,7 +293,16 @@ class ClimbingDataManager: ObservableObject {
|
||||
}
|
||||
|
||||
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 }
|
||||
saveAttempts()
|
||||
|
||||
@@ -326,9 +344,6 @@ class ClimbingDataManager: ObservableObject {
|
||||
saveSessions()
|
||||
DataStateManager.shared.updateDataState()
|
||||
|
||||
successMessage = "Session started successfully"
|
||||
clearMessageAfterDelay()
|
||||
|
||||
// MARK: - Start Live Activity for new session
|
||||
if let gym = gym(withId: gymId) {
|
||||
Task {
|
||||
@@ -337,8 +352,17 @@ class ClimbingDataManager: ObservableObject {
|
||||
}
|
||||
}
|
||||
|
||||
// Trigger auto-sync if enabled
|
||||
syncService.triggerAutoSync(dataManager: self)
|
||||
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) {
|
||||
@@ -356,8 +380,6 @@ class ClimbingDataManager: ObservableObject {
|
||||
saveActiveSession()
|
||||
saveSessions()
|
||||
DataStateManager.shared.updateDataState()
|
||||
successMessage = "Session completed successfully"
|
||||
clearMessageAfterDelay()
|
||||
|
||||
// Trigger auto-sync if enabled
|
||||
syncService.triggerAutoSync(dataManager: self)
|
||||
@@ -366,6 +388,17 @@ class ClimbingDataManager: ObservableObject {
|
||||
Task {
|
||||
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)")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -380,19 +413,28 @@ class ClimbingDataManager: ObservableObject {
|
||||
|
||||
saveSessions()
|
||||
DataStateManager.shared.updateDataState()
|
||||
successMessage = "Session updated successfully"
|
||||
clearMessageAfterDelay()
|
||||
|
||||
// Update Live Activity when session is updated
|
||||
updateLiveActivityForActiveSession()
|
||||
|
||||
// Trigger auto-sync if enabled
|
||||
syncService.triggerAutoSync(dataManager: self)
|
||||
// Only trigger sync if session is completed
|
||||
if session.status != .active {
|
||||
syncService.triggerAutoSync(dataManager: self)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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 }
|
||||
saveAttempts()
|
||||
|
||||
@@ -406,8 +448,6 @@ class ClimbingDataManager: ObservableObject {
|
||||
sessions.removeAll { $0.id == session.id }
|
||||
saveSessions()
|
||||
DataStateManager.shared.updateDataState()
|
||||
successMessage = "Session deleted successfully"
|
||||
clearMessageAfterDelay()
|
||||
|
||||
// Update Live Activity when session is deleted
|
||||
updateLiveActivityForActiveSession()
|
||||
@@ -435,12 +475,6 @@ class ClimbingDataManager: ObservableObject {
|
||||
saveAttempts()
|
||||
DataStateManager.shared.updateDataState()
|
||||
|
||||
successMessage = "Attempt logged successfully"
|
||||
|
||||
// Trigger auto-sync if enabled
|
||||
syncService.triggerAutoSync(dataManager: self)
|
||||
clearMessageAfterDelay()
|
||||
|
||||
// Update Live Activity when new attempt is added
|
||||
updateLiveActivityForActiveSession()
|
||||
}
|
||||
@@ -450,35 +484,56 @@ class ClimbingDataManager: ObservableObject {
|
||||
attempts[index] = attempt
|
||||
saveAttempts()
|
||||
DataStateManager.shared.updateDataState()
|
||||
successMessage = "Attempt updated successfully"
|
||||
clearMessageAfterDelay()
|
||||
|
||||
// Update Live Activity when attempt is updated
|
||||
updateLiveActivityForActiveSession()
|
||||
|
||||
// Trigger auto-sync if enabled
|
||||
syncService.triggerAutoSync(dataManager: self)
|
||||
}
|
||||
}
|
||||
|
||||
func deleteAttempt(_ attempt: Attempt) {
|
||||
attempts.removeAll { $0.id == attempt.id }
|
||||
trackDeletion(itemId: attempt.id.uuidString, itemType: "attempt")
|
||||
saveAttempts()
|
||||
DataStateManager.shared.updateDataState()
|
||||
successMessage = "Attempt deleted successfully"
|
||||
clearMessageAfterDelay()
|
||||
|
||||
// Update Live Activity when attempt is deleted
|
||||
updateLiveActivityForActiveSession()
|
||||
|
||||
// Trigger auto-sync if enabled
|
||||
syncService.triggerAutoSync(dataManager: self)
|
||||
}
|
||||
|
||||
func attempts(forSession sessionId: UUID) -> [Attempt] {
|
||||
return attempts.filter { $0.sessionId == sessionId }.sorted { $0.timestamp < $1.timestamp }
|
||||
}
|
||||
|
||||
// MARK: - Deletion Tracking
|
||||
|
||||
private func trackDeletion(itemId: String, itemType: String) {
|
||||
let deletion = DeletedItem(
|
||||
id: itemId,
|
||||
type: itemType,
|
||||
deletedAt: ISO8601DateFormatter().string(from: Date())
|
||||
)
|
||||
|
||||
var currentDeletions = getDeletedItems()
|
||||
currentDeletions.append(deletion)
|
||||
|
||||
if let data = try? encoder.encode(currentDeletions) {
|
||||
userDefaults.set(data, forKey: Keys.deletedItems)
|
||||
}
|
||||
}
|
||||
|
||||
func getDeletedItems() -> [DeletedItem] {
|
||||
guard let data = userDefaults.data(forKey: Keys.deletedItems),
|
||||
let deletions = try? decoder.decode([DeletedItem].self, from: data)
|
||||
else {
|
||||
return []
|
||||
}
|
||||
return deletions
|
||||
}
|
||||
|
||||
func clearDeletedItems() {
|
||||
userDefaults.removeObject(forKey: Keys.deletedItems)
|
||||
}
|
||||
|
||||
func attempts(forProblem problemId: UUID) -> [Attempt] {
|
||||
return attempts.filter { $0.problemId == problemId }.sorted { $0.timestamp > $1.timestamp }
|
||||
}
|
||||
@@ -516,6 +571,162 @@ class ClimbingDataManager: ObservableObject {
|
||||
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) {
|
||||
gyms.removeAll()
|
||||
problems.removeAll()
|
||||
@@ -542,6 +753,7 @@ class ClimbingDataManager: ObservableObject {
|
||||
let dateFormatter = DateFormatter()
|
||||
dateFormatter.dateFormat = "yyyy-MM-dd'T'HH:mm:ss.SSSSSS"
|
||||
|
||||
// Create export data with normalized image paths
|
||||
let exportData = ClimbDataBackup(
|
||||
exportedAt: dateFormatter.string(from: Date()),
|
||||
version: "2.0",
|
||||
@@ -552,7 +764,7 @@ class ClimbingDataManager: ObservableObject {
|
||||
attempts: attempts.map { BackupAttempt(from: $0) }
|
||||
)
|
||||
|
||||
// Collect referenced image paths
|
||||
// Collect actual image paths from disk for the ZIP
|
||||
let referencedImagePaths = collectReferencedImagePaths()
|
||||
print("Starting export with \(referencedImagePaths.count) images")
|
||||
|
||||
@@ -671,17 +883,19 @@ extension ClimbingDataManager {
|
||||
"Problem '\(problem.name ?? "Unnamed")' has \(problem.imagePaths.count) images"
|
||||
)
|
||||
for imagePath in problem.imagePaths {
|
||||
print(" - Relative path: \(imagePath)")
|
||||
let fullPath = ImageManager.shared.getFullPath(from: imagePath)
|
||||
print(" - Full path: \(fullPath)")
|
||||
print(" - Stored path: \(imagePath)")
|
||||
|
||||
// Extract just the filename (migration should have normalized these)
|
||||
let filename = URL(fileURLWithPath: imagePath).lastPathComponent
|
||||
let fullPath = ImageManager.shared.getFullPath(from: filename)
|
||||
print(" - Full disk path: \(fullPath)")
|
||||
|
||||
// Check if file exists
|
||||
if FileManager.default.fileExists(atPath: fullPath) {
|
||||
print(" File exists")
|
||||
print(" ✓ File exists")
|
||||
imagePaths.insert(fullPath)
|
||||
} else {
|
||||
print(" File does NOT exist")
|
||||
// Still add it to let ZipUtils handle the error logging
|
||||
print(" ✗ WARNING: File not found at \(fullPath)")
|
||||
// Still add it to let ZipUtils handle the logging
|
||||
imagePaths.insert(fullPath)
|
||||
}
|
||||
}
|
||||
@@ -697,11 +911,53 @@ extension ClimbingDataManager {
|
||||
imagePathMapping: [String: String]
|
||||
) -> [BackupProblem] {
|
||||
return problems.map { problem in
|
||||
let updatedImagePaths = (problem.imagePaths ?? []).compactMap { oldPath in
|
||||
let fileName = URL(fileURLWithPath: oldPath).lastPathComponent
|
||||
return imagePathMapping[fileName]
|
||||
guard let originalImagePaths = problem.imagePaths, !originalImagePaths.isEmpty else {
|
||||
return problem
|
||||
}
|
||||
return problem.withUpdatedImagePaths(updatedImagePaths)
|
||||
|
||||
var deterministicImagePaths: [String] = []
|
||||
|
||||
for (index, oldPath) in originalImagePaths.enumerated() {
|
||||
let originalFileName = URL(fileURLWithPath: oldPath).lastPathComponent
|
||||
|
||||
let deterministicName = ImageNamingUtils.generateImageFilename(
|
||||
problemId: problem.id, imageIndex: index)
|
||||
|
||||
if let tempFileName = imagePathMapping[originalFileName] {
|
||||
let imageManager = ImageManager.shared
|
||||
let tempPath = imageManager.imagesDirectory.appendingPathComponent(tempFileName)
|
||||
let deterministicPath = imageManager.imagesDirectory.appendingPathComponent(
|
||||
deterministicName)
|
||||
|
||||
do {
|
||||
if FileManager.default.fileExists(atPath: tempPath.path) {
|
||||
try FileManager.default.moveItem(at: tempPath, to: deterministicPath)
|
||||
|
||||
let tempBackupPath = imageManager.backupDirectory
|
||||
.appendingPathComponent(tempFileName)
|
||||
let deterministicBackupPath = imageManager.backupDirectory
|
||||
.appendingPathComponent(deterministicName)
|
||||
|
||||
if FileManager.default.fileExists(atPath: tempBackupPath.path) {
|
||||
try? FileManager.default.moveItem(
|
||||
at: tempBackupPath, to: deterministicBackupPath)
|
||||
}
|
||||
|
||||
deterministicImagePaths.append(deterministicName)
|
||||
print("Renamed imported image: \(tempFileName) → \(deterministicName)")
|
||||
}
|
||||
} catch {
|
||||
print(
|
||||
"Failed to rename imported image \(tempFileName) to \(deterministicName): \(error)"
|
||||
)
|
||||
deterministicImagePaths.append(tempFileName)
|
||||
}
|
||||
} else {
|
||||
deterministicImagePaths.append(deterministicName)
|
||||
}
|
||||
}
|
||||
|
||||
return problem.withUpdatedImagePaths(deterministicImagePaths)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -927,6 +1183,19 @@ extension ClimbingDataManager {
|
||||
}
|
||||
}
|
||||
|
||||
private func setupMigrationNotifications() {
|
||||
migrationObserver = NotificationCenter.default.addObserver(
|
||||
forName: NSNotification.Name("ImageMigrationCompleted"),
|
||||
object: nil,
|
||||
queue: .main
|
||||
) { [weak self] notification in
|
||||
if let updateCount = notification.userInfo?["updateCount"] as? Int {
|
||||
print("🔔 Image migration completed with \(updateCount) updates - reloading data")
|
||||
self?.loadProblems()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Handle Live Activity being dismissed by user
|
||||
private func handleLiveActivityDismissed() async {
|
||||
guard let activeSession = activeSession,
|
||||
|
||||
@@ -10,14 +10,10 @@ final class LiveActivityManager {
|
||||
static let shared = LiveActivityManager()
|
||||
private init() {}
|
||||
|
||||
private var currentActivity: Activity<SessionActivityAttributes>?
|
||||
nonisolated(unsafe) private var currentActivity: Activity<SessionActivityAttributes>?
|
||||
private var healthCheckTimer: Timer?
|
||||
private var lastHealthCheck: Date = Date()
|
||||
|
||||
deinit {
|
||||
healthCheckTimer?.invalidate()
|
||||
}
|
||||
|
||||
/// Check if there's an active session and restart Live Activity if needed
|
||||
func restartLiveActivityIfNeeded(activeSession: ClimbSession?, gymName: String?) async {
|
||||
// If we have an active session but no Live Activity, restart it
|
||||
@@ -130,8 +126,8 @@ final class LiveActivityManager {
|
||||
completedProblems: completedProblems
|
||||
)
|
||||
|
||||
await currentActivity.update(.init(state: updatedContentState, staleDate: nil))
|
||||
print("Live Activity updated successfully")
|
||||
nonisolated(unsafe) let activity = currentActivity
|
||||
await activity.update(.init(state: updatedContentState, staleDate: nil))
|
||||
}
|
||||
|
||||
/// Call this when a ClimbSession ends to end the Live Activity
|
||||
@@ -142,7 +138,8 @@ final class LiveActivityManager {
|
||||
// First end the tracked activity if it exists
|
||||
if let currentActivity {
|
||||
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
|
||||
print("Tracked Live Activity ended successfully")
|
||||
}
|
||||
@@ -201,7 +198,7 @@ final class LiveActivityManager {
|
||||
print("🩺 Starting Live Activity health checks")
|
||||
healthCheckTimer = Timer.scheduledTimer(withTimeInterval: 30.0, repeats: true) {
|
||||
[weak self] _ in
|
||||
Task { @MainActor in
|
||||
Task { @MainActor [weak self] in
|
||||
await self?.performHealthCheck()
|
||||
}
|
||||
}
|
||||
@@ -258,7 +255,7 @@ final class LiveActivityManager {
|
||||
{
|
||||
guard currentActivity != nil else { return }
|
||||
|
||||
Task {
|
||||
Task { @MainActor in
|
||||
while currentActivity != nil {
|
||||
let elapsed = Date().timeIntervalSince(session.startTime ?? session.date)
|
||||
await updateLiveActivity(
|
||||
|
||||
@@ -23,6 +23,22 @@ struct AddAttemptView: View {
|
||||
@State private var selectedPhotos: [PhotosPickerItem] = []
|
||||
@State private var imageData: [Data] = []
|
||||
|
||||
enum SheetType: Identifiable {
|
||||
case photoOptions
|
||||
case camera
|
||||
|
||||
var id: Int {
|
||||
switch self {
|
||||
case .photoOptions: return 0
|
||||
case .camera: return 1
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@State private var activeSheet: SheetType?
|
||||
@State private var showPhotoPicker = false
|
||||
@State private var isPhotoPickerActionPending = false
|
||||
|
||||
private var activeProblems: [Problem] {
|
||||
dataManager.activeProblems(forGym: gym.id)
|
||||
}
|
||||
@@ -78,6 +94,56 @@ struct AddAttemptView: View {
|
||||
.onChange(of: selectedDifficultySystem) {
|
||||
resetGradeIfNeeded()
|
||||
}
|
||||
.onChange(of: selectedPhotos) {
|
||||
Task {
|
||||
await loadSelectedPhotos()
|
||||
}
|
||||
}
|
||||
|
||||
.photosPicker(
|
||||
isPresented: $showPhotoPicker,
|
||||
selection: $selectedPhotos,
|
||||
maxSelectionCount: 5 - imageData.count,
|
||||
matching: .images
|
||||
)
|
||||
.sheet(
|
||||
item: $activeSheet,
|
||||
onDismiss: {
|
||||
if isPhotoPickerActionPending {
|
||||
showPhotoPicker = true
|
||||
isPhotoPickerActionPending = false
|
||||
}
|
||||
}
|
||||
) { sheetType in
|
||||
switch sheetType {
|
||||
case .photoOptions:
|
||||
PhotoOptionSheet(
|
||||
selectedPhotos: $selectedPhotos,
|
||||
imageData: $imageData,
|
||||
maxImages: 5,
|
||||
onCameraSelected: {
|
||||
activeSheet = .camera
|
||||
},
|
||||
onPhotoLibrarySelected: {
|
||||
isPhotoPickerActionPending = true
|
||||
},
|
||||
onDismiss: {
|
||||
activeSheet = nil
|
||||
}
|
||||
)
|
||||
case .camera:
|
||||
CameraImagePicker(
|
||||
isPresented: Binding(
|
||||
get: { activeSheet == .camera },
|
||||
set: { if !$0 { activeSheet = nil } }
|
||||
)
|
||||
) { capturedImage in
|
||||
if let jpegData = capturedImage.jpegData(compressionQuality: 0.8) {
|
||||
imageData.append(jpegData)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
@@ -216,11 +282,9 @@ struct AddAttemptView: View {
|
||||
}
|
||||
|
||||
Section("Photos (Optional)") {
|
||||
PhotosPicker(
|
||||
selection: $selectedPhotos,
|
||||
maxSelectionCount: 5,
|
||||
matching: .images
|
||||
) {
|
||||
Button(action: {
|
||||
activeSheet = .photoOptions
|
||||
}) {
|
||||
HStack {
|
||||
Image(systemName: "camera.fill")
|
||||
.foregroundColor(.blue)
|
||||
@@ -240,11 +304,7 @@ struct AddAttemptView: View {
|
||||
}
|
||||
.padding(.vertical, 4)
|
||||
}
|
||||
.onChange(of: selectedPhotos) { _, _ in
|
||||
Task {
|
||||
await loadSelectedPhotos()
|
||||
}
|
||||
}
|
||||
.disabled(imageData.count >= 5)
|
||||
|
||||
if !imageData.isEmpty {
|
||||
ScrollView(.horizontal, showsIndicators: false) {
|
||||
@@ -378,29 +438,56 @@ struct AddAttemptView: View {
|
||||
}
|
||||
}
|
||||
|
||||
private func loadSelectedPhotos() async {
|
||||
var newImageData: [Data] = []
|
||||
|
||||
for item in selectedPhotos {
|
||||
if let data = try? await item.loadTransferable(type: Data.self) {
|
||||
newImageData.append(data)
|
||||
}
|
||||
}
|
||||
|
||||
await MainActor.run {
|
||||
imageData.append(contentsOf: newImageData)
|
||||
selectedPhotos.removeAll()
|
||||
}
|
||||
}
|
||||
|
||||
private func saveAttempt() {
|
||||
if showingCreateProblem {
|
||||
let difficulty = DifficultyGrade(
|
||||
system: selectedDifficultySystem, grade: newProblemGrade)
|
||||
|
||||
// Save images and get paths
|
||||
var imagePaths: [String] = []
|
||||
for data in imageData {
|
||||
if let relativePath = ImageManager.shared.saveImageData(data) {
|
||||
imagePaths.append(relativePath)
|
||||
}
|
||||
}
|
||||
|
||||
let newProblem = Problem(
|
||||
gymId: gym.id,
|
||||
name: newProblemName.isEmpty ? nil : newProblemName,
|
||||
climbType: selectedClimbType,
|
||||
difficulty: difficulty,
|
||||
imagePaths: imagePaths
|
||||
imagePaths: []
|
||||
)
|
||||
|
||||
dataManager.addProblem(newProblem)
|
||||
|
||||
if !imageData.isEmpty {
|
||||
var imagePaths: [String] = []
|
||||
|
||||
for (index, data) in imageData.enumerated() {
|
||||
let deterministicName = ImageNamingUtils.generateImageFilename(
|
||||
problemId: newProblem.id.uuidString, imageIndex: index)
|
||||
|
||||
if let relativePath = ImageManager.shared.saveImageData(
|
||||
data, withName: deterministicName)
|
||||
{
|
||||
imagePaths.append(relativePath)
|
||||
}
|
||||
}
|
||||
|
||||
if !imagePaths.isEmpty {
|
||||
let updatedProblem = newProblem.updated(imagePaths: imagePaths)
|
||||
dataManager.updateProblem(updatedProblem)
|
||||
}
|
||||
}
|
||||
|
||||
let attempt = Attempt(
|
||||
sessionId: session.id,
|
||||
problemId: newProblem.id,
|
||||
@@ -436,19 +523,6 @@ struct AddAttemptView: View {
|
||||
dismiss()
|
||||
}
|
||||
|
||||
private func loadSelectedPhotos() async {
|
||||
var newImageData: [Data] = []
|
||||
|
||||
for item in selectedPhotos {
|
||||
if let data = try? await item.loadTransferable(type: Data.self) {
|
||||
newImageData.append(data)
|
||||
}
|
||||
}
|
||||
|
||||
await MainActor.run {
|
||||
imageData = newImageData
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct ProblemSelectionRow: View {
|
||||
@@ -696,6 +770,22 @@ struct EditAttemptView: View {
|
||||
@State private var selectedPhotos: [PhotosPickerItem] = []
|
||||
@State private var imageData: [Data] = []
|
||||
|
||||
enum SheetType: Identifiable {
|
||||
case photoOptions
|
||||
case camera
|
||||
|
||||
var id: Int {
|
||||
switch self {
|
||||
case .photoOptions: return 0
|
||||
case .camera: return 1
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@State private var activeSheet: SheetType?
|
||||
@State private var showPhotoPicker = false
|
||||
@State private var isPhotoPickerActionPending = false
|
||||
|
||||
private var availableProblems: [Problem] {
|
||||
guard let session = dataManager.session(withId: attempt.sessionId) else {
|
||||
return []
|
||||
@@ -772,6 +862,56 @@ struct EditAttemptView: View {
|
||||
.onChange(of: selectedDifficultySystem) {
|
||||
resetGradeIfNeeded()
|
||||
}
|
||||
.onChange(of: selectedPhotos) {
|
||||
Task {
|
||||
await loadSelectedPhotos()
|
||||
}
|
||||
}
|
||||
|
||||
.photosPicker(
|
||||
isPresented: $showPhotoPicker,
|
||||
selection: $selectedPhotos,
|
||||
maxSelectionCount: 5 - imageData.count,
|
||||
matching: .images
|
||||
)
|
||||
.sheet(
|
||||
item: $activeSheet,
|
||||
onDismiss: {
|
||||
if isPhotoPickerActionPending {
|
||||
showPhotoPicker = true
|
||||
isPhotoPickerActionPending = false
|
||||
}
|
||||
}
|
||||
) { sheetType in
|
||||
switch sheetType {
|
||||
case .photoOptions:
|
||||
PhotoOptionSheet(
|
||||
selectedPhotos: $selectedPhotos,
|
||||
imageData: $imageData,
|
||||
maxImages: 5,
|
||||
onCameraSelected: {
|
||||
activeSheet = .camera
|
||||
},
|
||||
onPhotoLibrarySelected: {
|
||||
isPhotoPickerActionPending = true
|
||||
},
|
||||
onDismiss: {
|
||||
activeSheet = nil
|
||||
}
|
||||
)
|
||||
case .camera:
|
||||
CameraImagePicker(
|
||||
isPresented: Binding(
|
||||
get: { activeSheet == .camera },
|
||||
set: { if !$0 { activeSheet = nil } }
|
||||
)
|
||||
) { capturedImage in
|
||||
if let jpegData = capturedImage.jpegData(compressionQuality: 0.8) {
|
||||
imageData.append(jpegData)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
@@ -910,11 +1050,9 @@ struct EditAttemptView: View {
|
||||
}
|
||||
|
||||
Section("Photos (Optional)") {
|
||||
PhotosPicker(
|
||||
selection: $selectedPhotos,
|
||||
maxSelectionCount: 5,
|
||||
matching: .images
|
||||
) {
|
||||
Button(action: {
|
||||
activeSheet = .photoOptions
|
||||
}) {
|
||||
HStack {
|
||||
Image(systemName: "camera.fill")
|
||||
.foregroundColor(.blue)
|
||||
@@ -934,11 +1072,7 @@ struct EditAttemptView: View {
|
||||
}
|
||||
.padding(.vertical, 4)
|
||||
}
|
||||
.onChange(of: selectedPhotos) { _, _ in
|
||||
Task {
|
||||
await loadSelectedPhotos()
|
||||
}
|
||||
}
|
||||
.disabled(imageData.count >= 5)
|
||||
|
||||
if !imageData.isEmpty {
|
||||
ScrollView(.horizontal, showsIndicators: false) {
|
||||
@@ -1074,6 +1208,21 @@ struct EditAttemptView: View {
|
||||
}
|
||||
}
|
||||
|
||||
private func loadSelectedPhotos() async {
|
||||
var newImageData: [Data] = []
|
||||
|
||||
for item in selectedPhotos {
|
||||
if let data = try? await item.loadTransferable(type: Data.self) {
|
||||
newImageData.append(data)
|
||||
}
|
||||
}
|
||||
|
||||
await MainActor.run {
|
||||
imageData.append(contentsOf: newImageData)
|
||||
selectedPhotos.removeAll()
|
||||
}
|
||||
}
|
||||
|
||||
private func updateAttempt() {
|
||||
if showingCreateProblem {
|
||||
guard let gym = gym else { return }
|
||||
@@ -1081,24 +1230,36 @@ struct EditAttemptView: View {
|
||||
let difficulty = DifficultyGrade(
|
||||
system: selectedDifficultySystem, grade: newProblemGrade)
|
||||
|
||||
// Save images and get paths
|
||||
var imagePaths: [String] = []
|
||||
for data in imageData {
|
||||
if let relativePath = ImageManager.shared.saveImageData(data) {
|
||||
imagePaths.append(relativePath)
|
||||
}
|
||||
}
|
||||
|
||||
let newProblem = Problem(
|
||||
gymId: gym.id,
|
||||
name: newProblemName.isEmpty ? nil : newProblemName,
|
||||
climbType: selectedClimbType,
|
||||
difficulty: difficulty,
|
||||
imagePaths: imagePaths
|
||||
imagePaths: []
|
||||
)
|
||||
|
||||
dataManager.addProblem(newProblem)
|
||||
|
||||
if !imageData.isEmpty {
|
||||
var imagePaths: [String] = []
|
||||
|
||||
for (index, data) in imageData.enumerated() {
|
||||
let deterministicName = ImageNamingUtils.generateImageFilename(
|
||||
problemId: newProblem.id.uuidString, imageIndex: index)
|
||||
|
||||
if let relativePath = ImageManager.shared.saveImageData(
|
||||
data, withName: deterministicName)
|
||||
{
|
||||
imagePaths.append(relativePath)
|
||||
}
|
||||
}
|
||||
|
||||
if !imagePaths.isEmpty {
|
||||
let updatedProblem = newProblem.updated(imagePaths: imagePaths)
|
||||
dataManager.updateProblem(updatedProblem)
|
||||
}
|
||||
}
|
||||
|
||||
let updatedAttempt = attempt.updated(
|
||||
problemId: newProblem.id,
|
||||
result: selectedResult,
|
||||
@@ -1131,19 +1292,6 @@ struct EditAttemptView: View {
|
||||
dismiss()
|
||||
}
|
||||
|
||||
private func loadSelectedPhotos() async {
|
||||
var newImageData: [Data] = []
|
||||
|
||||
for item in selectedPhotos {
|
||||
if let data = try? await item.loadTransferable(type: Data.self) {
|
||||
newImageData.append(data)
|
||||
}
|
||||
}
|
||||
|
||||
await MainActor.run {
|
||||
imageData = newImageData
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#Preview {
|
||||
@@ -1160,129 +1308,19 @@ struct EditAttemptView: View {
|
||||
|
||||
struct ProblemSelectionImageView: View {
|
||||
let imagePath: String
|
||||
@State private var uiImage: UIImage?
|
||||
@State private var isLoading = true
|
||||
@State private var hasFailed = false
|
||||
|
||||
var body: some View {
|
||||
Group {
|
||||
if let uiImage = uiImage {
|
||||
Image(uiImage: uiImage)
|
||||
.resizable()
|
||||
.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
|
||||
}
|
||||
}
|
||||
}
|
||||
OrientationAwareImage.fill(imagePath: imagePath)
|
||||
.frame(height: 80)
|
||||
.clipped()
|
||||
.cornerRadius(8)
|
||||
}
|
||||
}
|
||||
|
||||
struct ProblemSelectionImageFullView: View {
|
||||
let imagePath: String
|
||||
@State private var uiImage: UIImage?
|
||||
@State private var isLoading = true
|
||||
@State private var hasFailed = false
|
||||
|
||||
var body: some View {
|
||||
Group {
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
OrientationAwareImage.fit(imagePath: imagePath)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -22,6 +22,21 @@ struct AddEditProblemView: View {
|
||||
@State private var selectedPhotos: [PhotosPickerItem] = []
|
||||
@State private var imageData: [Data] = []
|
||||
@State private var isEditing = false
|
||||
enum SheetType: Identifiable {
|
||||
case photoOptions
|
||||
case camera
|
||||
|
||||
var id: Int {
|
||||
switch self {
|
||||
case .photoOptions: return 0
|
||||
case .camera: return 1
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@State private var activeSheet: SheetType?
|
||||
@State private var showPhotoPicker = false
|
||||
@State private var isPhotoPickerActionPending = false
|
||||
|
||||
private var existingProblem: Problem? {
|
||||
guard let problemId = problemId else { return nil }
|
||||
@@ -87,6 +102,12 @@ struct AddEditProblemView: View {
|
||||
loadExistingProblem()
|
||||
setupInitialGym()
|
||||
}
|
||||
.onChange(of: dataManager.gyms) {
|
||||
// Ensure a gym is selected when gyms are loaded or changed
|
||||
if selectedGym == nil && !dataManager.gyms.isEmpty {
|
||||
selectedGym = dataManager.gyms.first
|
||||
}
|
||||
}
|
||||
.onChange(of: selectedGym) {
|
||||
updateAvailableOptions()
|
||||
}
|
||||
@@ -96,11 +117,56 @@ struct AddEditProblemView: View {
|
||||
.onChange(of: selectedDifficultySystem) {
|
||||
resetGradeIfNeeded()
|
||||
}
|
||||
.sheet(
|
||||
item: $activeSheet,
|
||||
onDismiss: {
|
||||
if isPhotoPickerActionPending {
|
||||
showPhotoPicker = true
|
||||
isPhotoPickerActionPending = false
|
||||
}
|
||||
}
|
||||
) { sheetType in
|
||||
switch sheetType {
|
||||
case .photoOptions:
|
||||
PhotoOptionSheet(
|
||||
selectedPhotos: $selectedPhotos,
|
||||
imageData: $imageData,
|
||||
maxImages: 5,
|
||||
onCameraSelected: {
|
||||
activeSheet = .camera
|
||||
},
|
||||
onPhotoLibrarySelected: {
|
||||
isPhotoPickerActionPending = true
|
||||
},
|
||||
onDismiss: {
|
||||
activeSheet = nil
|
||||
}
|
||||
)
|
||||
case .camera:
|
||||
CameraImagePicker(
|
||||
isPresented: Binding(
|
||||
get: { activeSheet == .camera },
|
||||
set: { if !$0 { activeSheet = nil } }
|
||||
)
|
||||
) { capturedImage in
|
||||
if let jpegData = capturedImage.jpegData(compressionQuality: 0.8) {
|
||||
imageData.append(jpegData)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.photosPicker(
|
||||
isPresented: $showPhotoPicker,
|
||||
selection: $selectedPhotos,
|
||||
maxSelectionCount: 5 - imageData.count,
|
||||
matching: .images
|
||||
)
|
||||
.onChange(of: selectedPhotos) {
|
||||
Task {
|
||||
await loadSelectedPhotos()
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
@@ -302,11 +368,9 @@ struct AddEditProblemView: View {
|
||||
@ViewBuilder
|
||||
private func PhotosSection() -> some View {
|
||||
Section("Photos (Optional)") {
|
||||
PhotosPicker(
|
||||
selection: $selectedPhotos,
|
||||
maxSelectionCount: 5,
|
||||
matching: .images
|
||||
) {
|
||||
Button(action: {
|
||||
activeSheet = .photoOptions
|
||||
}) {
|
||||
HStack {
|
||||
Image(systemName: "camera.fill")
|
||||
.foregroundColor(.blue)
|
||||
@@ -326,6 +390,7 @@ struct AddEditProblemView: View {
|
||||
}
|
||||
.padding(.vertical, 4)
|
||||
}
|
||||
.disabled(imageData.count >= 5)
|
||||
|
||||
if !imageData.isEmpty {
|
||||
ScrollView(.horizontal, showsIndicators: false) {
|
||||
@@ -398,9 +463,14 @@ struct AddEditProblemView: View {
|
||||
}
|
||||
|
||||
private func setupInitialGym() {
|
||||
if let gymId = gymId, selectedGym == nil {
|
||||
if let gymId = gymId {
|
||||
selectedGym = dataManager.gym(withId: gymId)
|
||||
}
|
||||
|
||||
// Always ensure a gym is selected if available and none is currently selected
|
||||
if selectedGym == nil && !dataManager.gyms.isEmpty {
|
||||
selectedGym = dataManager.gyms.first
|
||||
}
|
||||
}
|
||||
|
||||
private func loadExistingProblem() {
|
||||
@@ -466,18 +536,14 @@ struct AddEditProblemView: View {
|
||||
private func loadSelectedPhotos() async {
|
||||
for item in selectedPhotos {
|
||||
if let data = try? await item.loadTransferable(type: Data.self) {
|
||||
// Use ImageManager to save image
|
||||
if let relativePath = ImageManager.shared.saveImageData(data) {
|
||||
imagePaths.append(relativePath)
|
||||
imageData.append(data)
|
||||
}
|
||||
imageData.append(data)
|
||||
}
|
||||
}
|
||||
selectedPhotos.removeAll()
|
||||
}
|
||||
|
||||
private func saveProblem() {
|
||||
guard let gym = selectedGym else { return }
|
||||
guard let gym = selectedGym, canSave else { return }
|
||||
|
||||
let trimmedName = name.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
let trimmedDescription = description.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
@@ -491,6 +557,24 @@ struct AddEditProblemView: View {
|
||||
let difficulty = DifficultyGrade(system: selectedDifficultySystem, grade: difficultyGrade)
|
||||
|
||||
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(
|
||||
name: trimmedName.isEmpty ? nil : trimmedName,
|
||||
description: trimmedDescription.isEmpty ? nil : trimmedDescription,
|
||||
@@ -499,7 +583,7 @@ struct AddEditProblemView: View {
|
||||
|
||||
tags: trimmedTags,
|
||||
location: trimmedLocation.isEmpty ? nil : trimmedLocation,
|
||||
imagePaths: imagePaths,
|
||||
imagePaths: allImagePaths,
|
||||
isActive: isActive,
|
||||
dateSet: dateSet,
|
||||
notes: trimmedNotes.isEmpty ? nil : trimmedNotes
|
||||
@@ -515,11 +599,32 @@ struct AddEditProblemView: View {
|
||||
|
||||
tags: trimmedTags,
|
||||
location: trimmedLocation.isEmpty ? nil : trimmedLocation,
|
||||
imagePaths: imagePaths,
|
||||
imagePaths: [],
|
||||
dateSet: dateSet,
|
||||
notes: trimmedNotes.isEmpty ? nil : trimmedNotes
|
||||
)
|
||||
|
||||
dataManager.addProblem(newProblem)
|
||||
|
||||
if !imageData.isEmpty {
|
||||
var imagePaths: [String] = []
|
||||
|
||||
for (index, data) in imageData.enumerated() {
|
||||
let deterministicName = ImageNamingUtils.generateImageFilename(
|
||||
problemId: newProblem.id.uuidString, imageIndex: index)
|
||||
|
||||
if let relativePath = ImageManager.shared.saveImageData(
|
||||
data, withName: deterministicName)
|
||||
{
|
||||
imagePaths.append(relativePath)
|
||||
}
|
||||
}
|
||||
|
||||
if !imagePaths.isEmpty {
|
||||
let updatedProblem = newProblem.updated(imagePaths: imagePaths)
|
||||
dataManager.updateProblem(updatedProblem)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
dismiss()
|
||||
|
||||
@@ -443,128 +443,20 @@ struct ImageViewerView: View {
|
||||
|
||||
struct ProblemDetailImageView: View {
|
||||
let imagePath: String
|
||||
@State private var uiImage: UIImage?
|
||||
@State private var isLoading = true
|
||||
@State private var hasFailed = false
|
||||
|
||||
var body: some View {
|
||||
Group {
|
||||
if let uiImage = uiImage {
|
||||
Image(uiImage: uiImage)
|
||||
.resizable()
|
||||
.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
|
||||
}
|
||||
}
|
||||
}
|
||||
OrientationAwareImage.fill(imagePath: imagePath)
|
||||
.frame(width: 120, height: 120)
|
||||
.clipped()
|
||||
.cornerRadius(12)
|
||||
}
|
||||
}
|
||||
|
||||
struct ProblemDetailImageFullView: View {
|
||||
let imagePath: String
|
||||
@State private var uiImage: UIImage?
|
||||
@State private var isLoading = true
|
||||
@State private var hasFailed = false
|
||||
|
||||
var body: some View {
|
||||
Group {
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
OrientationAwareImage.fit(imagePath: imagePath)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -9,8 +9,26 @@ struct ProblemsView: View {
|
||||
@State private var showingSearch = false
|
||||
@FocusState private var isSearchFocused: Bool
|
||||
|
||||
private var filteredProblems: [Problem] {
|
||||
var filtered = dataManager.problems
|
||||
@State private var cachedFilteredProblems: [Problem] = []
|
||||
|
||||
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
|
||||
if !searchText.isEmpty {
|
||||
@@ -32,9 +50,19 @@ struct ProblemsView: View {
|
||||
filtered = filtered.filter { $0.gymId == gym.id }
|
||||
}
|
||||
|
||||
// Separate active and inactive problems
|
||||
let active = filtered.filter { $0.isActive }.sorted { $0.updatedAt > $1.updatedAt }
|
||||
let inactive = filtered.filter { !$0.isActive }.sorted { $0.updatedAt > $1.updatedAt }
|
||||
// Separate active and inactive problems with stable sorting
|
||||
let active = filtered.filter { $0.isActive }.sorted {
|
||||
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
|
||||
}
|
||||
@@ -83,19 +111,19 @@ struct ProblemsView: View {
|
||||
FilterSection(
|
||||
selectedClimbType: $selectedClimbType,
|
||||
selectedGym: $selectedGym,
|
||||
filteredProblems: filteredProblems
|
||||
filteredProblems: cachedFilteredProblems
|
||||
)
|
||||
.padding()
|
||||
.background(.regularMaterial)
|
||||
}
|
||||
|
||||
if filteredProblems.isEmpty {
|
||||
if cachedFilteredProblems.isEmpty {
|
||||
EmptyProblemsView(
|
||||
isEmpty: dataManager.problems.isEmpty,
|
||||
isFiltered: !dataManager.problems.isEmpty
|
||||
)
|
||||
} else {
|
||||
ProblemsList(problems: filteredProblems)
|
||||
ProblemsList(problems: cachedFilteredProblems)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -148,6 +176,21 @@ struct ProblemsView: View {
|
||||
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
|
||||
@State private var problemToDelete: Problem?
|
||||
@State private var problemToEdit: Problem?
|
||||
@State private var animationKey = 0
|
||||
|
||||
var body: some View {
|
||||
List(problems) { problem in
|
||||
List(problems, id: \.id) { problem in
|
||||
NavigationLink(destination: ProblemDetailView(problemId: problem.id)) {
|
||||
ProblemRow(problem: problem)
|
||||
}
|
||||
@@ -273,8 +317,12 @@ struct ProblemsList: View {
|
||||
}
|
||||
|
||||
Button {
|
||||
let updatedProblem = problem.updated(isActive: !problem.isActive)
|
||||
dataManager.updateProblem(updatedProblem)
|
||||
// Use a spring animation for more natural movement
|
||||
withAnimation(.spring(response: 0.5, dampingFraction: 0.8, blendDuration: 0.1))
|
||||
{
|
||||
let updatedProblem = problem.updated(isActive: !problem.isActive)
|
||||
dataManager.updateProblem(updatedProblem)
|
||||
}
|
||||
} label: {
|
||||
Label(
|
||||
problem.isActive ? "Mark as Reset" : "Mark as Active",
|
||||
@@ -293,6 +341,17 @@ struct ProblemsList: View {
|
||||
.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)) {
|
||||
Button("Cancel", role: .cancel) {
|
||||
problemToDelete = nil
|
||||
@@ -322,6 +381,12 @@ struct ProblemRow: View {
|
||||
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 {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
HStack {
|
||||
@@ -339,10 +404,24 @@ struct ProblemRow: View {
|
||||
Spacer()
|
||||
|
||||
VStack(alignment: .trailing, spacing: 4) {
|
||||
Text(problem.difficulty.grade)
|
||||
.font(.title2)
|
||||
.fontWeight(.bold)
|
||||
.foregroundColor(.blue)
|
||||
HStack(spacing: 8) {
|
||||
if !problem.imagePaths.isEmpty {
|
||||
Image(systemName: "photo")
|
||||
.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)
|
||||
.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 {
|
||||
Text("Reset / No Longer Set")
|
||||
.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 {
|
||||
ProblemsView()
|
||||
.environmentObject(ClimbingDataManager.preview)
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import HealthKit
|
||||
import SwiftUI
|
||||
import UniformTypeIdentifiers
|
||||
|
||||
@@ -16,6 +17,9 @@ struct SettingsView: View {
|
||||
SyncSection()
|
||||
.environmentObject(dataManager.syncService)
|
||||
|
||||
HealthKitSection()
|
||||
.environmentObject(dataManager.healthKitService)
|
||||
|
||||
DataManagementSection(
|
||||
activeSheet: $activeSheet
|
||||
)
|
||||
@@ -77,6 +81,9 @@ struct DataManagementSection: View {
|
||||
@State private var showingResetAlert = false
|
||||
@State private var isExporting = false
|
||||
|
||||
@State private var isDeletingImages = false
|
||||
@State private var showingDeleteImagesAlert = false
|
||||
|
||||
var body: some View {
|
||||
Section("Data Management") {
|
||||
// Export Data
|
||||
@@ -113,6 +120,27 @@ struct DataManagementSection: View {
|
||||
}
|
||||
.foregroundColor(.primary)
|
||||
|
||||
// Delete All Images
|
||||
Button(action: {
|
||||
showingDeleteImagesAlert = true
|
||||
}) {
|
||||
HStack {
|
||||
if isDeletingImages {
|
||||
ProgressView()
|
||||
.scaleEffect(0.8)
|
||||
Text("Deleting Images...")
|
||||
.foregroundColor(.secondary)
|
||||
} else {
|
||||
Image(systemName: "trash")
|
||||
.foregroundColor(.red)
|
||||
Text("Delete All Images")
|
||||
}
|
||||
Spacer()
|
||||
}
|
||||
}
|
||||
.disabled(isDeletingImages)
|
||||
.foregroundColor(.red)
|
||||
|
||||
// Reset All Data
|
||||
Button(action: {
|
||||
showingResetAlert = true
|
||||
@@ -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."
|
||||
)
|
||||
}
|
||||
|
||||
.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() {
|
||||
@@ -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 {
|
||||
@@ -162,7 +259,7 @@ struct AppInfoSection: View {
|
||||
var body: some View {
|
||||
Section("App Information") {
|
||||
HStack {
|
||||
Image("MountainsIcon")
|
||||
Image("AppLogo")
|
||||
.resizable()
|
||||
.frame(width: 24, height: 24)
|
||||
VStack(alignment: .leading) {
|
||||
@@ -236,7 +333,7 @@ struct ExportDataView: View {
|
||||
item: fileURL,
|
||||
preview: SharePreview(
|
||||
"OpenClimb Data Export",
|
||||
image: Image("MountainsIcon"))
|
||||
image: Image("AppLogo"))
|
||||
) {
|
||||
Label("Share Data", systemImage: "square.and.arrow.up")
|
||||
.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 {
|
||||
SettingsView()
|
||||
.environmentObject(ClimbingDataManager.preview)
|
||||
|
||||
@@ -252,4 +252,277 @@ final class OpenClimbTests: XCTestCase {
|
||||
XCTAssertNotNil(parsedDate)
|
||||
XCTAssertEqual(date.timeIntervalSince1970, parsedDate!.timeIntervalSince1970, accuracy: 1.0)
|
||||
}
|
||||
|
||||
// MARK: - Active Session Preservation Tests
|
||||
|
||||
func testActiveSessionPreservationDuringImport() throws {
|
||||
// Test that active sessions are preserved during import operations
|
||||
// This tests the fix for the bug where active sessions disappear after sync
|
||||
|
||||
// Simulate an active session that exists locally but not in import data
|
||||
let activeSessionId = UUID()
|
||||
let gymId = UUID()
|
||||
|
||||
// Test data structure representing local active session
|
||||
let localActiveSession: [String: Any] = [
|
||||
"id": activeSessionId.uuidString,
|
||||
"gymId": gymId.uuidString,
|
||||
"status": "active",
|
||||
"date": "2024-01-01",
|
||||
"startTime": "2024-01-01T10:00:00Z",
|
||||
]
|
||||
|
||||
// Test data structure representing server sessions (without the active one)
|
||||
let serverSessions: [[String: Any]] = [
|
||||
[
|
||||
"id": UUID().uuidString,
|
||||
"gymId": gymId.uuidString,
|
||||
"status": "completed",
|
||||
"date": "2023-12-31",
|
||||
"startTime": "2023-12-31T15:00:00Z",
|
||||
"endTime": "2023-12-31T17:00:00Z",
|
||||
]
|
||||
]
|
||||
|
||||
// Verify test setup
|
||||
XCTAssertEqual(localActiveSession["status"] as? String, "active")
|
||||
XCTAssertEqual(serverSessions.count, 1)
|
||||
XCTAssertEqual(serverSessions[0]["status"] as? String, "completed")
|
||||
|
||||
// Verify that the active session ID is not in the server sessions
|
||||
let serverSessionIds = serverSessions.compactMap { $0["id"] as? String }
|
||||
XCTAssertFalse(serverSessionIds.contains(activeSessionId.uuidString))
|
||||
|
||||
// Test that we can identify an active session
|
||||
if let status = localActiveSession["status"] as? String {
|
||||
XCTAssertTrue(status == "active")
|
||||
} else {
|
||||
XCTFail("Failed to extract session status")
|
||||
}
|
||||
|
||||
// Test session ID validation
|
||||
if let sessionIdString = localActiveSession["id"] as? String,
|
||||
let sessionId = UUID(uuidString: sessionIdString)
|
||||
{
|
||||
XCTAssertEqual(sessionId, activeSessionId)
|
||||
} else {
|
||||
XCTFail("Failed to parse session ID")
|
||||
}
|
||||
|
||||
// Test that combining sessions preserves both local active and server completed
|
||||
var combinedSessions = serverSessions
|
||||
combinedSessions.append(localActiveSession)
|
||||
|
||||
XCTAssertEqual(combinedSessions.count, 2)
|
||||
|
||||
// Verify both session types are present
|
||||
let hasActiveSession = combinedSessions.contains { session in
|
||||
(session["status"] as? String) == "active"
|
||||
}
|
||||
let hasCompletedSession = combinedSessions.contains { session in
|
||||
(session["status"] as? String) == "completed"
|
||||
}
|
||||
|
||||
XCTAssertTrue(hasActiveSession, "Combined sessions should contain active 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
@@ -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.
|
||||
@@ -5,8 +5,8 @@ import ActivityKit
|
||||
import SwiftUI
|
||||
import WidgetKit
|
||||
|
||||
struct SessionActivityAttributes: ActivityAttributes {
|
||||
public struct ContentState: Codable, Hashable {
|
||||
struct SessionActivityAttributes: ActivityAttributes, Sendable {
|
||||
public struct ContentState: Codable, Hashable, Sendable {
|
||||
var elapsed: TimeInterval
|
||||
var totalAttempts: Int
|
||||
var completedProblems: Int
|
||||
|
||||
37
sync/README.md
Normal 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.
|
||||
17
sync/main.go
@@ -13,6 +13,8 @@ import (
|
||||
"time"
|
||||
)
|
||||
|
||||
const VERSION = "1.1.0"
|
||||
|
||||
func min(a, b int) int {
|
||||
if a < b {
|
||||
return a
|
||||
@@ -20,6 +22,12 @@ func min(a, b int) int {
|
||||
return b
|
||||
}
|
||||
|
||||
type DeletedItem struct {
|
||||
ID string `json:"id"`
|
||||
Type string `json:"type"`
|
||||
DeletedAt string `json:"deletedAt"`
|
||||
}
|
||||
|
||||
type ClimbDataBackup struct {
|
||||
ExportedAt string `json:"exportedAt"`
|
||||
Version string `json:"version"`
|
||||
@@ -28,6 +36,7 @@ type ClimbDataBackup struct {
|
||||
Problems []BackupProblem `json:"problems"`
|
||||
Sessions []BackupClimbSession `json:"sessions"`
|
||||
Attempts []BackupAttempt `json:"attempts"`
|
||||
DeletedItems []DeletedItem `json:"deletedItems"`
|
||||
}
|
||||
|
||||
type BackupGym struct {
|
||||
@@ -120,6 +129,7 @@ func (s *SyncServer) loadData() (*ClimbDataBackup, error) {
|
||||
Problems: []BackupProblem{},
|
||||
Sessions: []BackupClimbSession{},
|
||||
Attempts: []BackupAttempt{},
|
||||
DeletedItems: []DeletedItem{},
|
||||
}, nil
|
||||
}
|
||||
|
||||
@@ -215,8 +225,9 @@ func (s *SyncServer) handleHealth(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
json.NewEncoder(w).Encode(map[string]string{
|
||||
"status": "healthy",
|
||||
"time": time.Now().UTC().Format(time.RFC3339),
|
||||
"status": "healthy",
|
||||
"version": VERSION,
|
||||
"time": time.Now().UTC().Format(time.RFC3339),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -347,7 +358,7 @@ func main() {
|
||||
http.HandleFunc("/images/upload", server.handleImageUpload)
|
||||
http.HandleFunc("/images/download", server.handleImageDownload)
|
||||
|
||||
fmt.Printf("OpenClimb sync server starting on port %s\n", port)
|
||||
fmt.Printf("OpenClimb sync server v%s starting on port %s\n", VERSION, port)
|
||||
fmt.Printf("Data file: %s\n", dataFile)
|
||||
fmt.Printf("Images directory: %s\n", imagesDir)
|
||||
fmt.Printf("Health check available at /health\n")
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
1.0.0
|
||||