diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts index 72cbf9e..8dd927e 100644 --- a/android/app/build.gradle.kts +++ b/android/app/build.gradle.kts @@ -16,8 +16,8 @@ android { applicationId = "com.atridad.openclimb" minSdk = 31 targetSdk = 36 - versionCode = 37 - versionName = "1.9.0" + versionCode = 38 + versionName = "1.9.1" testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" } diff --git a/android/app/src/main/java/com/atridad/openclimb/data/sync/SyncService.kt b/android/app/src/main/java/com/atridad/openclimb/data/sync/SyncService.kt index 5ca5f0d..9176b5f 100644 --- a/android/app/src/main/java/com/atridad/openclimb/data/sync/SyncService.kt +++ b/android/app/src/main/java/com/atridad/openclimb/data/sync/SyncService.kt @@ -88,6 +88,9 @@ class SyncService(private val context: Context, private val repository: ClimbRep private val _isTesting = MutableStateFlow(false) val isTesting: StateFlow = _isTesting.asStateFlow() + private val _isAutoSyncEnabled = MutableStateFlow(true) + val isAutoSyncEnabled: StateFlow = _isAutoSyncEnabled.asStateFlow() + // Debounced sync properties private var syncJob: Job? = null private var pendingChanges = false @@ -130,15 +133,15 @@ class SyncService(private val context: Context, private val repository: ClimbRep _isConfigured.value = serverURL.isNotEmpty() && authToken.isNotEmpty() } - var isAutoSyncEnabled: Boolean - get() = sharedPreferences.getBoolean(Keys.AUTO_SYNC_ENABLED, true) - set(value) { - sharedPreferences.edit().putBoolean(Keys.AUTO_SYNC_ENABLED, value).apply() - } + fun setAutoSyncEnabled(enabled: Boolean) { + sharedPreferences.edit().putBoolean(Keys.AUTO_SYNC_ENABLED, enabled).apply() + _isAutoSyncEnabled.value = enabled + } init { _isConnected.value = sharedPreferences.getBoolean(Keys.IS_CONNECTED, false) _lastSyncTime.value = sharedPreferences.getString(Keys.LAST_SYNC_TIME, null) + _isAutoSyncEnabled.value = sharedPreferences.getBoolean(Keys.AUTO_SYNC_ENABLED, true) updateConfiguredState() repository.setAutoSyncCallback { @@ -1104,7 +1107,7 @@ class SyncService(private val context: Context, private val repository: ClimbRep } suspend fun triggerAutoSync() { - if (!isConfigured || !_isConnected.value || !isAutoSyncEnabled) { + if (!isConfigured || !_isConnected.value || !_isAutoSyncEnabled.value) { return } @@ -1159,7 +1162,7 @@ class SyncService(private val context: Context, private val repository: ClimbRep serverURL = "" authToken = "" - isAutoSyncEnabled = true + setAutoSyncEnabled(true) _lastSyncTime.value = null _isConnected.value = false _syncError.value = null diff --git a/android/app/src/main/java/com/atridad/openclimb/ui/components/FullscreenImageViewer.kt b/android/app/src/main/java/com/atridad/openclimb/ui/components/FullscreenImageViewer.kt index e4d74ed..7be80c5 100644 --- a/android/app/src/main/java/com/atridad/openclimb/ui/components/FullscreenImageViewer.kt +++ b/android/app/src/main/java/com/atridad/openclimb/ui/components/FullscreenImageViewer.kt @@ -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, - initialIndex: Int = 0, - onDismiss: () -> Unit -) { +fun FullscreenImageViewer(imagePaths: List, 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 - ) - } -} diff --git a/android/app/src/main/java/com/atridad/openclimb/ui/components/ImageDisplay.kt b/android/app/src/main/java/com/atridad/openclimb/ui/components/ImageDisplay.kt index 61cbb90..de190f4 100644 --- a/android/app/src/main/java/com/atridad/openclimb/ui/components/ImageDisplay.kt +++ b/android/app/src/main/java/com/atridad/openclimb/ui/components/ImageDisplay.kt @@ -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, - modifier: Modifier = Modifier, - imageSize: Int = 120, - onImageClick: ((Int) -> Unit)? = null + imagePaths: List, + 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, - modifier: Modifier = Modifier, - title: String = "Photos", - onImageClick: ((Int) -> Unit)? = null + imagePaths: List, + 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) } } } diff --git a/android/app/src/main/java/com/atridad/openclimb/ui/components/ImagePicker.kt b/android/app/src/main/java/com/atridad/openclimb/ui/components/ImagePicker.kt index a744058..41fdf53 100644 --- a/android/app/src/main/java/com/atridad/openclimb/ui/components/ImagePicker.kt +++ b/android/app/src/main/java/com/atridad/openclimb/ui/components/ImagePicker.kt @@ -25,7 +25,6 @@ import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.unit.dp import androidx.core.content.ContextCompat import androidx.core.content.FileProvider -import coil.compose.AsyncImage import com.atridad.openclimb.utils.ImageUtils import java.io.File import java.text.SimpleDateFormat @@ -259,8 +258,8 @@ private fun ImageItem(imagePath: String, onRemove: () -> Unit, modifier: Modifie val imageFile = ImageUtils.getImageFile(context, imagePath) Box(modifier = modifier.size(80.dp)) { - AsyncImage( - model = imageFile, + OrientationAwareImage( + imagePath = imagePath, contentDescription = "Problem photo", modifier = Modifier.fillMaxSize().clip(RoundedCornerShape(8.dp)), contentScale = ContentScale.Crop diff --git a/android/app/src/main/java/com/atridad/openclimb/ui/components/OrientationAwareImage.kt b/android/app/src/main/java/com/atridad/openclimb/ui/components/OrientationAwareImage.kt new file mode 100644 index 0000000..3d6f73d --- /dev/null +++ b/android/app/src/main/java/com/atridad/openclimb/ui/components/OrientationAwareImage.kt @@ -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(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 + } +} diff --git a/android/app/src/main/java/com/atridad/openclimb/ui/screens/SettingsScreen.kt b/android/app/src/main/java/com/atridad/openclimb/ui/screens/SettingsScreen.kt index f8dc11f..10a483e 100644 --- a/android/app/src/main/java/com/atridad/openclimb/ui/screens/SettingsScreen.kt +++ b/android/app/src/main/java/com/atridad/openclimb/ui/screens/SettingsScreen.kt @@ -38,6 +38,7 @@ 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) } @@ -280,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) + } ) } } diff --git a/android/app/src/main/java/com/atridad/openclimb/utils/ImageUtils.kt b/android/app/src/main/java/com/atridad/openclimb/utils/ImageUtils.kt index ed4c480..9a8ae77 100644 --- a/android/app/src/main/java/com/atridad/openclimb/utils/ImageUtils.kt +++ b/android/app/src/main/java/com/atridad/openclimb/utils/ImageUtils.kt @@ -8,6 +8,7 @@ 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 @@ -27,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, @@ -42,10 +93,7 @@ object ImageUtils { } ?: return null - val orientedBitmap = correctImageOrientation(context, imageUri, originalBitmap) - val compressedBitmap = compressImage(orientedBitmap) - - // Always require deterministic naming - no UUID fallback + // Always require deterministic naming require(problemId != null && imageIndex != null) { "Problem ID and image index are required for deterministic image naming" } @@ -53,15 +101,10 @@ object ImageUtils { 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) { @@ -221,23 +264,13 @@ object ImageUtils { MediaStore.Images.Media.getBitmap(context.contentResolver, imageUri) ?: return null - val orientedBitmap = correctImageOrientation(context, imageUri, originalBitmap) - val compressedBitmap = compressImage(orientedBitmap) - val tempFilename = "temp_${UUID.randomUUID()}.jpg" val imageFile = File(getImagesDirectory(context), tempFilename) - FileOutputStream(imageFile).use { output -> - compressedBitmap.compress(Bitmap.CompressFormat.JPEG, IMAGE_QUALITY, output) - } - + val success = saveImageWithExif(context, imageUri, originalBitmap, imageFile) originalBitmap.recycle() - if (orientedBitmap != originalBitmap) { - orientedBitmap.recycle() - } - if (compressedBitmap != orientedBitmap) { - compressedBitmap.recycle() - } + + if (!success) return null tempFilename } catch (e: Exception) { @@ -315,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" diff --git a/ios/OpenClimb.xcodeproj/project.pbxproj b/ios/OpenClimb.xcodeproj/project.pbxproj index e374a54..48e7589 100644 --- a/ios/OpenClimb.xcodeproj/project.pbxproj +++ b/ios/OpenClimb.xcodeproj/project.pbxproj @@ -465,7 +465,7 @@ CODE_SIGN_ENTITLEMENTS = OpenClimb/OpenClimb.entitlements; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 23; + CURRENT_PROJECT_VERSION = 24; DEVELOPMENT_TEAM = 4BC9Y2LL4B; DRIVERKIT_DEPLOYMENT_TARGET = 24.6; ENABLE_PREVIEWS = YES; @@ -513,7 +513,7 @@ CODE_SIGN_ENTITLEMENTS = OpenClimb/OpenClimb.entitlements; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 23; + CURRENT_PROJECT_VERSION = 24; DEVELOPMENT_TEAM = 4BC9Y2LL4B; DRIVERKIT_DEPLOYMENT_TARGET = 24.6; ENABLE_PREVIEWS = YES; @@ -602,7 +602,7 @@ ASSETCATALOG_COMPILER_WIDGET_BACKGROUND_COLOR_NAME = WidgetBackground; CODE_SIGN_ENTITLEMENTS = SessionStatusLiveExtension.entitlements; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 23; + CURRENT_PROJECT_VERSION = 24; DEVELOPMENT_TEAM = 4BC9Y2LL4B; GENERATE_INFOPLIST_FILE = YES; INFOPLIST_FILE = SessionStatusLive/Info.plist; @@ -632,7 +632,7 @@ ASSETCATALOG_COMPILER_WIDGET_BACKGROUND_COLOR_NAME = WidgetBackground; CODE_SIGN_ENTITLEMENTS = SessionStatusLiveExtension.entitlements; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 23; + CURRENT_PROJECT_VERSION = 24; DEVELOPMENT_TEAM = 4BC9Y2LL4B; GENERATE_INFOPLIST_FILE = YES; INFOPLIST_FILE = SessionStatusLive/Info.plist; diff --git a/ios/OpenClimb.xcodeproj/project.xcworkspace/xcuserdata/atridad.xcuserdatad/UserInterfaceState.xcuserstate b/ios/OpenClimb.xcodeproj/project.xcworkspace/xcuserdata/atridad.xcuserdatad/UserInterfaceState.xcuserstate index b91ded5..f6ec780 100644 Binary files a/ios/OpenClimb.xcodeproj/project.xcworkspace/xcuserdata/atridad.xcuserdatad/UserInterfaceState.xcuserstate and b/ios/OpenClimb.xcodeproj/project.xcworkspace/xcuserdata/atridad.xcuserdatad/UserInterfaceState.xcuserstate differ diff --git a/ios/OpenClimb/Utils/ImageManager.swift b/ios/OpenClimb/Utils/ImageManager.swift index f52ab5f..e10e8e3 100644 --- a/ios/OpenClimb/Utils/ImageManager.swift +++ b/ios/OpenClimb/Utils/ImageManager.swift @@ -1,5 +1,6 @@ import Foundation import SwiftUI +import UIKit class ImageManager { static let shared = ImageManager() diff --git a/ios/OpenClimb/Utils/OrientationAwareImage.swift b/ios/OpenClimb/Utils/OrientationAwareImage.swift new file mode 100644 index 0000000..a12c228 --- /dev/null +++ b/ios/OpenClimb/Utils/OrientationAwareImage.swift @@ -0,0 +1,154 @@ +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 { + let correctedImage = await loadAndCorrectImage() + await MainActor.run { + self.uiImage = correctedImage + self.isLoading = false + self.hasFailed = correctedImage == nil + } + } + } + + private func loadAndCorrectImage() async -> UIImage? { + // Load image data from ImageManager + guard + let data = await MainActor.run(body: { + ImageManager.shared.loadImageData(fromPath: imagePath) + }) + else { return nil } + + // Create UIImage from data + guard let originalImage = UIImage(data: data) else { return nil } + + // Apply orientation correction + 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) + } +} diff --git a/ios/OpenClimb/Views/AddEdit/AddAttemptView.swift b/ios/OpenClimb/Views/AddEdit/AddAttemptView.swift index 3eadd0b..240640c 100644 --- a/ios/OpenClimb/Views/AddEdit/AddAttemptView.swift +++ b/ios/OpenClimb/Views/AddEdit/AddAttemptView.swift @@ -1308,131 +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 - } - - Task { - let data = await MainActor.run { - ImageManager.shared.loadImageData(fromPath: imagePath) - } - - if let data = data, let image = UIImage(data: data) { - await MainActor.run { - self.uiImage = image - self.isLoading = false - } - } else { - await MainActor.run { - 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) } } diff --git a/ios/OpenClimb/Views/Detail/ProblemDetailView.swift b/ios/OpenClimb/Views/Detail/ProblemDetailView.swift index cffe9b2..e37bc53 100644 --- a/ios/OpenClimb/Views/Detail/ProblemDetailView.swift +++ b/ios/OpenClimb/Views/Detail/ProblemDetailView.swift @@ -443,132 +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 - } - - Task { - let data = await MainActor.run { - ImageManager.shared.loadImageData(fromPath: imagePath) - } - - if let data = data, let image = UIImage(data: data) { - await MainActor.run { - self.uiImage = image - self.isLoading = false - } - } else { - await MainActor.run { - 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 - } - - Task { - let data = await MainActor.run { - ImageManager.shared.loadImageData(fromPath: imagePath) - } - - if let data = data, let image = UIImage(data: data) { - await MainActor.run { - self.uiImage = image - self.isLoading = false - } - } else { - await MainActor.run { - self.hasFailed = true - self.isLoading = false - } - } - } + OrientationAwareImage.fit(imagePath: imagePath) } } diff --git a/ios/OpenClimb/Views/ProblemsView.swift b/ios/OpenClimb/Views/ProblemsView.swift index f4c2e2f..3c113b6 100644 --- a/ios/OpenClimb/Views/ProblemsView.swift +++ b/ios/OpenClimb/Views/ProblemsView.swift @@ -480,81 +480,12 @@ 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 let imageCache: NSCache = { - let cache = NSCache() - 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 - } - - // Load image asynchronously - Task { @MainActor in - let cacheKey = NSString(string: imagePath) - - // Check cache first - if let cachedImage = Self.imageCache.object(forKey: cacheKey) { - self.uiImage = cachedImage - self.isLoading = false - return - } - - // Load image data - if let data = ImageManager.shared.loadImageData(fromPath: imagePath), - let image = UIImage(data: data) - { - // Cache the image - Self.imageCache.setObject(image, forKey: cacheKey) - self.uiImage = image - self.isLoading = false - } else { - self.hasFailed = true - self.isLoading = false - } - } + OrientationAwareImage.fill(imagePath: imagePath) + .frame(width: 60, height: 60) + .clipped() + .cornerRadius(8) } }