[Android] 1.9.1 - EXIF Fixes

This commit is contained in:
2025-10-12 01:46:16 -06:00
parent 77f8110d85
commit 405fb06d5d
15 changed files with 527 additions and 542 deletions

View File

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

View File

@@ -88,6 +88,9 @@ class SyncService(private val context: Context, private val repository: ClimbRep
private val _isTesting = MutableStateFlow(false)
val isTesting: StateFlow<Boolean> = _isTesting.asStateFlow()
private val _isAutoSyncEnabled = MutableStateFlow(true)
val isAutoSyncEnabled: StateFlow<Boolean> = _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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,5 +1,6 @@
import Foundation
import SwiftUI
import UIKit
class ImageManager {
static let shared = ImageManager()

View File

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

View File

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

View File

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

View File

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