[Android] 1.9.1 - EXIF Fixes
This commit is contained in:
@@ -16,8 +16,8 @@ android {
|
|||||||
applicationId = "com.atridad.openclimb"
|
applicationId = "com.atridad.openclimb"
|
||||||
minSdk = 31
|
minSdk = 31
|
||||||
targetSdk = 36
|
targetSdk = 36
|
||||||
versionCode = 37
|
versionCode = 38
|
||||||
versionName = "1.9.0"
|
versionName = "1.9.1"
|
||||||
|
|
||||||
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -88,6 +88,9 @@ class SyncService(private val context: Context, private val repository: ClimbRep
|
|||||||
private val _isTesting = MutableStateFlow(false)
|
private val _isTesting = MutableStateFlow(false)
|
||||||
val isTesting: StateFlow<Boolean> = _isTesting.asStateFlow()
|
val isTesting: StateFlow<Boolean> = _isTesting.asStateFlow()
|
||||||
|
|
||||||
|
private val _isAutoSyncEnabled = MutableStateFlow(true)
|
||||||
|
val isAutoSyncEnabled: StateFlow<Boolean> = _isAutoSyncEnabled.asStateFlow()
|
||||||
|
|
||||||
// Debounced sync properties
|
// Debounced sync properties
|
||||||
private var syncJob: Job? = null
|
private var syncJob: Job? = null
|
||||||
private var pendingChanges = false
|
private var pendingChanges = false
|
||||||
@@ -130,15 +133,15 @@ class SyncService(private val context: Context, private val repository: ClimbRep
|
|||||||
_isConfigured.value = serverURL.isNotEmpty() && authToken.isNotEmpty()
|
_isConfigured.value = serverURL.isNotEmpty() && authToken.isNotEmpty()
|
||||||
}
|
}
|
||||||
|
|
||||||
var isAutoSyncEnabled: Boolean
|
fun setAutoSyncEnabled(enabled: Boolean) {
|
||||||
get() = sharedPreferences.getBoolean(Keys.AUTO_SYNC_ENABLED, true)
|
sharedPreferences.edit().putBoolean(Keys.AUTO_SYNC_ENABLED, enabled).apply()
|
||||||
set(value) {
|
_isAutoSyncEnabled.value = enabled
|
||||||
sharedPreferences.edit().putBoolean(Keys.AUTO_SYNC_ENABLED, value).apply()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
init {
|
init {
|
||||||
_isConnected.value = sharedPreferences.getBoolean(Keys.IS_CONNECTED, false)
|
_isConnected.value = sharedPreferences.getBoolean(Keys.IS_CONNECTED, false)
|
||||||
_lastSyncTime.value = sharedPreferences.getString(Keys.LAST_SYNC_TIME, null)
|
_lastSyncTime.value = sharedPreferences.getString(Keys.LAST_SYNC_TIME, null)
|
||||||
|
_isAutoSyncEnabled.value = sharedPreferences.getBoolean(Keys.AUTO_SYNC_ENABLED, true)
|
||||||
updateConfiguredState()
|
updateConfiguredState()
|
||||||
|
|
||||||
repository.setAutoSyncCallback {
|
repository.setAutoSyncCallback {
|
||||||
@@ -1104,7 +1107,7 @@ class SyncService(private val context: Context, private val repository: ClimbRep
|
|||||||
}
|
}
|
||||||
|
|
||||||
suspend fun triggerAutoSync() {
|
suspend fun triggerAutoSync() {
|
||||||
if (!isConfigured || !_isConnected.value || !isAutoSyncEnabled) {
|
if (!isConfigured || !_isConnected.value || !_isAutoSyncEnabled.value) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1159,7 +1162,7 @@ class SyncService(private val context: Context, private val repository: ClimbRep
|
|||||||
|
|
||||||
serverURL = ""
|
serverURL = ""
|
||||||
authToken = ""
|
authToken = ""
|
||||||
isAutoSyncEnabled = true
|
setAutoSyncEnabled(true)
|
||||||
_lastSyncTime.value = null
|
_lastSyncTime.value = null
|
||||||
_isConnected.value = false
|
_isConnected.value = false
|
||||||
_syncError.value = null
|
_syncError.value = null
|
||||||
|
|||||||
@@ -3,7 +3,6 @@ package com.atridad.openclimb.ui.components
|
|||||||
import androidx.compose.foundation.ExperimentalFoundationApi
|
import androidx.compose.foundation.ExperimentalFoundationApi
|
||||||
import androidx.compose.foundation.background
|
import androidx.compose.foundation.background
|
||||||
import androidx.compose.foundation.clickable
|
import androidx.compose.foundation.clickable
|
||||||
import androidx.compose.foundation.gestures.detectTransformGestures
|
|
||||||
import androidx.compose.foundation.layout.*
|
import androidx.compose.foundation.layout.*
|
||||||
import androidx.compose.foundation.lazy.LazyRow
|
import androidx.compose.foundation.lazy.LazyRow
|
||||||
import androidx.compose.foundation.lazy.itemsIndexed
|
import androidx.compose.foundation.lazy.itemsIndexed
|
||||||
@@ -20,88 +19,60 @@ import androidx.compose.ui.Alignment
|
|||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.draw.clip
|
import androidx.compose.ui.draw.clip
|
||||||
import androidx.compose.ui.graphics.Color
|
import androidx.compose.ui.graphics.Color
|
||||||
import androidx.compose.ui.graphics.graphicsLayer
|
|
||||||
import androidx.compose.ui.input.pointer.pointerInput
|
|
||||||
import androidx.compose.ui.layout.ContentScale
|
import androidx.compose.ui.layout.ContentScale
|
||||||
import androidx.compose.ui.platform.LocalContext
|
import androidx.compose.ui.platform.LocalContext
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.compose.ui.window.Dialog
|
import androidx.compose.ui.window.Dialog
|
||||||
import androidx.compose.ui.window.DialogProperties
|
import androidx.compose.ui.window.DialogProperties
|
||||||
import coil.compose.AsyncImage
|
|
||||||
import com.atridad.openclimb.utils.ImageUtils
|
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
|
|
||||||
@OptIn(ExperimentalFoundationApi::class)
|
@OptIn(ExperimentalFoundationApi::class)
|
||||||
@Composable
|
@Composable
|
||||||
fun FullscreenImageViewer(
|
fun FullscreenImageViewer(imagePaths: List<String>, initialIndex: Int = 0, onDismiss: () -> Unit) {
|
||||||
imagePaths: List<String>,
|
|
||||||
initialIndex: Int = 0,
|
|
||||||
onDismiss: () -> Unit
|
|
||||||
) {
|
|
||||||
val context = LocalContext.current
|
val context = LocalContext.current
|
||||||
val pagerState = rememberPagerState(
|
val pagerState = rememberPagerState(initialPage = initialIndex, pageCount = { imagePaths.size })
|
||||||
initialPage = initialIndex,
|
|
||||||
pageCount = { imagePaths.size }
|
|
||||||
)
|
|
||||||
val thumbnailListState = rememberLazyListState()
|
val thumbnailListState = rememberLazyListState()
|
||||||
val coroutineScope = rememberCoroutineScope()
|
val coroutineScope = rememberCoroutineScope()
|
||||||
|
|
||||||
// Auto-scroll thumbnail list to center current image
|
// Auto-scroll thumbnail list to center current image
|
||||||
LaunchedEffect(pagerState.currentPage) {
|
LaunchedEffect(pagerState.currentPage) {
|
||||||
thumbnailListState.animateScrollToItem(
|
thumbnailListState.animateScrollToItem(index = pagerState.currentPage, scrollOffset = -200)
|
||||||
index = pagerState.currentPage,
|
|
||||||
scrollOffset = -200
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
Dialog(
|
Dialog(
|
||||||
onDismissRequest = onDismiss,
|
onDismissRequest = onDismiss,
|
||||||
properties = DialogProperties(
|
properties =
|
||||||
|
DialogProperties(
|
||||||
usePlatformDefaultWidth = false,
|
usePlatformDefaultWidth = false,
|
||||||
decorFitsSystemWindows = false
|
decorFitsSystemWindows = false
|
||||||
)
|
)
|
||||||
) {
|
) {
|
||||||
Box(
|
Box(modifier = Modifier.fillMaxSize().background(Color.Black)) {
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxSize()
|
|
||||||
.background(Color.Black)
|
|
||||||
) {
|
|
||||||
// Main image pager
|
// Main image pager
|
||||||
HorizontalPager(
|
HorizontalPager(state = pagerState, modifier = Modifier.fillMaxSize()) { page ->
|
||||||
state = pagerState,
|
OrientationAwareImage(
|
||||||
modifier = Modifier.fillMaxSize()
|
|
||||||
) { page ->
|
|
||||||
ZoomableImage(
|
|
||||||
imagePath = imagePaths[page],
|
imagePath = imagePaths[page],
|
||||||
modifier = Modifier.fillMaxSize()
|
contentDescription = "Full screen image",
|
||||||
|
modifier = Modifier.fillMaxSize(),
|
||||||
|
contentScale = ContentScale.Fit
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Close button
|
// Close button
|
||||||
IconButton(
|
IconButton(
|
||||||
onClick = onDismiss,
|
onClick = onDismiss,
|
||||||
modifier = Modifier
|
modifier =
|
||||||
.align(Alignment.TopEnd)
|
Modifier.align(Alignment.TopEnd)
|
||||||
.padding(16.dp)
|
.padding(16.dp)
|
||||||
.background(
|
.background(Color.Black.copy(alpha = 0.5f), CircleShape)
|
||||||
Color.Black.copy(alpha = 0.5f),
|
) { Icon(Icons.Default.Close, contentDescription = "Close", tint = Color.White) }
|
||||||
CircleShape
|
|
||||||
)
|
|
||||||
) {
|
|
||||||
Icon(
|
|
||||||
Icons.Default.Close,
|
|
||||||
contentDescription = "Close",
|
|
||||||
tint = Color.White
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Image counter
|
// Image counter
|
||||||
if (imagePaths.size > 1) {
|
if (imagePaths.size > 1) {
|
||||||
Card(
|
Card(
|
||||||
modifier = Modifier
|
modifier = Modifier.align(Alignment.TopCenter).padding(16.dp),
|
||||||
.align(Alignment.TopCenter)
|
colors =
|
||||||
.padding(16.dp),
|
CardDefaults.cardColors(
|
||||||
colors = CardDefaults.cardColors(
|
|
||||||
containerColor = Color.Black.copy(alpha = 0.7f)
|
containerColor = Color.Black.copy(alpha = 0.7f)
|
||||||
)
|
)
|
||||||
) {
|
) {
|
||||||
@@ -116,11 +87,12 @@ fun FullscreenImageViewer(
|
|||||||
// Thumbnail strip (if multiple images)
|
// Thumbnail strip (if multiple images)
|
||||||
if (imagePaths.size > 1) {
|
if (imagePaths.size > 1) {
|
||||||
Card(
|
Card(
|
||||||
modifier = Modifier
|
modifier =
|
||||||
.align(Alignment.BottomCenter)
|
Modifier.align(Alignment.BottomCenter)
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.padding(16.dp),
|
.padding(16.dp),
|
||||||
colors = CardDefaults.cardColors(
|
colors =
|
||||||
|
CardDefaults.cardColors(
|
||||||
containerColor = Color.Black.copy(alpha = 0.7f)
|
containerColor = Color.Black.copy(alpha = 0.7f)
|
||||||
)
|
)
|
||||||
) {
|
) {
|
||||||
@@ -131,14 +103,13 @@ fun FullscreenImageViewer(
|
|||||||
contentPadding = PaddingValues(horizontal = 8.dp)
|
contentPadding = PaddingValues(horizontal = 8.dp)
|
||||||
) {
|
) {
|
||||||
itemsIndexed(imagePaths) { index, imagePath ->
|
itemsIndexed(imagePaths) { index, imagePath ->
|
||||||
val imageFile = ImageUtils.getImageFile(context, imagePath)
|
|
||||||
val isSelected = index == pagerState.currentPage
|
val isSelected = index == pagerState.currentPage
|
||||||
|
|
||||||
AsyncImage(
|
OrientationAwareImage(
|
||||||
model = imageFile,
|
imagePath = imagePath,
|
||||||
contentDescription = "Thumbnail ${index + 1}",
|
contentDescription = "Thumbnail ${index + 1}",
|
||||||
modifier = Modifier
|
modifier =
|
||||||
.size(60.dp)
|
Modifier.size(60.dp)
|
||||||
.clip(RoundedCornerShape(8.dp))
|
.clip(RoundedCornerShape(8.dp))
|
||||||
.clickable {
|
.clickable {
|
||||||
coroutineScope.launch {
|
coroutineScope.launch {
|
||||||
@@ -148,7 +119,9 @@ fun FullscreenImageViewer(
|
|||||||
.then(
|
.then(
|
||||||
if (isSelected) {
|
if (isSelected) {
|
||||||
Modifier.background(
|
Modifier.background(
|
||||||
Color.White.copy(alpha = 0.3f),
|
Color.White.copy(
|
||||||
|
alpha = 0.3f
|
||||||
|
),
|
||||||
RoundedCornerShape(8.dp)
|
RoundedCornerShape(8.dp)
|
||||||
)
|
)
|
||||||
} else Modifier
|
} else Modifier
|
||||||
@@ -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,8 +12,6 @@ import androidx.compose.ui.draw.clip
|
|||||||
import androidx.compose.ui.layout.ContentScale
|
import androidx.compose.ui.layout.ContentScale
|
||||||
import androidx.compose.ui.platform.LocalContext
|
import androidx.compose.ui.platform.LocalContext
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import coil.compose.AsyncImage
|
|
||||||
import com.atridad.openclimb.utils.ImageUtils
|
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun ImageDisplay(
|
fun ImageDisplay(
|
||||||
@@ -25,18 +23,13 @@ fun ImageDisplay(
|
|||||||
val context = LocalContext.current
|
val context = LocalContext.current
|
||||||
|
|
||||||
if (imagePaths.isNotEmpty()) {
|
if (imagePaths.isNotEmpty()) {
|
||||||
LazyRow(
|
LazyRow(modifier = modifier, horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||||
modifier = modifier,
|
|
||||||
horizontalArrangement = Arrangement.spacedBy(8.dp)
|
|
||||||
) {
|
|
||||||
itemsIndexed(imagePaths) { index, imagePath ->
|
itemsIndexed(imagePaths) { index, imagePath ->
|
||||||
val imageFile = ImageUtils.getImageFile(context, imagePath)
|
OrientationAwareImage(
|
||||||
|
imagePath = imagePath,
|
||||||
AsyncImage(
|
|
||||||
model = imageFile,
|
|
||||||
contentDescription = "Problem photo",
|
contentDescription = "Problem photo",
|
||||||
modifier = Modifier
|
modifier =
|
||||||
.size(imageSize.dp)
|
Modifier.size(imageSize.dp)
|
||||||
.clip(RoundedCornerShape(8.dp))
|
.clip(RoundedCornerShape(8.dp))
|
||||||
.clickable(enabled = onImageClick != null) {
|
.clickable(enabled = onImageClick != null) {
|
||||||
onImageClick?.invoke(index)
|
onImageClick?.invoke(index)
|
||||||
@@ -65,11 +58,7 @@ fun ImageDisplaySection(
|
|||||||
|
|
||||||
Spacer(modifier = Modifier.height(8.dp))
|
Spacer(modifier = Modifier.height(8.dp))
|
||||||
|
|
||||||
ImageDisplay(
|
ImageDisplay(imagePaths = imagePaths, imageSize = 120, onImageClick = onImageClick)
|
||||||
imagePaths = imagePaths,
|
|
||||||
imageSize = 120,
|
|
||||||
onImageClick = onImageClick
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -25,7 +25,6 @@ import androidx.compose.ui.platform.LocalContext
|
|||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.core.content.ContextCompat
|
import androidx.core.content.ContextCompat
|
||||||
import androidx.core.content.FileProvider
|
import androidx.core.content.FileProvider
|
||||||
import coil.compose.AsyncImage
|
|
||||||
import com.atridad.openclimb.utils.ImageUtils
|
import com.atridad.openclimb.utils.ImageUtils
|
||||||
import java.io.File
|
import java.io.File
|
||||||
import java.text.SimpleDateFormat
|
import java.text.SimpleDateFormat
|
||||||
@@ -259,8 +258,8 @@ private fun ImageItem(imagePath: String, onRemove: () -> Unit, modifier: Modifie
|
|||||||
val imageFile = ImageUtils.getImageFile(context, imagePath)
|
val imageFile = ImageUtils.getImageFile(context, imagePath)
|
||||||
|
|
||||||
Box(modifier = modifier.size(80.dp)) {
|
Box(modifier = modifier.size(80.dp)) {
|
||||||
AsyncImage(
|
OrientationAwareImage(
|
||||||
model = imageFile,
|
imagePath = imagePath,
|
||||||
contentDescription = "Problem photo",
|
contentDescription = "Problem photo",
|
||||||
modifier = Modifier.fillMaxSize().clip(RoundedCornerShape(8.dp)),
|
modifier = Modifier.fillMaxSize().clip(RoundedCornerShape(8.dp)),
|
||||||
contentScale = ContentScale.Crop
|
contentScale = ContentScale.Crop
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -38,6 +38,7 @@ fun SettingsScreen(viewModel: ClimbViewModel) {
|
|||||||
val isTesting by syncService.isTesting.collectAsState()
|
val isTesting by syncService.isTesting.collectAsState()
|
||||||
val lastSyncTime by syncService.lastSyncTime.collectAsState()
|
val lastSyncTime by syncService.lastSyncTime.collectAsState()
|
||||||
val syncError by syncService.syncError.collectAsState()
|
val syncError by syncService.syncError.collectAsState()
|
||||||
|
val isAutoSyncEnabled by syncService.isAutoSyncEnabled.collectAsState()
|
||||||
|
|
||||||
// State for dialogs
|
// State for dialogs
|
||||||
var showResetDialog by remember { mutableStateOf(false) }
|
var showResetDialog by remember { mutableStateOf(false) }
|
||||||
@@ -280,8 +281,10 @@ fun SettingsScreen(viewModel: ClimbViewModel) {
|
|||||||
}
|
}
|
||||||
Spacer(modifier = Modifier.width(16.dp))
|
Spacer(modifier = Modifier.width(16.dp))
|
||||||
Switch(
|
Switch(
|
||||||
checked = syncService.isAutoSyncEnabled,
|
checked = isAutoSyncEnabled,
|
||||||
onCheckedChange = { syncService.isAutoSyncEnabled = it }
|
onCheckedChange = { enabled ->
|
||||||
|
syncService.setAutoSyncEnabled(enabled)
|
||||||
|
}
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import android.net.Uri
|
|||||||
import android.provider.MediaStore
|
import android.provider.MediaStore
|
||||||
import android.util.Log
|
import android.util.Log
|
||||||
import androidx.core.graphics.scale
|
import androidx.core.graphics.scale
|
||||||
|
import androidx.exifinterface.media.ExifInterface
|
||||||
import java.io.File
|
import java.io.File
|
||||||
import java.io.FileOutputStream
|
import java.io.FileOutputStream
|
||||||
import java.util.UUID
|
import java.util.UUID
|
||||||
@@ -27,7 +28,57 @@ object ImageUtils {
|
|||||||
return imagesDir
|
return imagesDir
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Saves an image from a URI with compression and proper orientation */
|
/** Saves an image from a URI while preserving EXIF orientation data */
|
||||||
|
private fun saveImageWithExif(
|
||||||
|
context: Context,
|
||||||
|
imageUri: Uri,
|
||||||
|
originalBitmap: Bitmap,
|
||||||
|
outputFile: File
|
||||||
|
): Boolean {
|
||||||
|
return try {
|
||||||
|
// Get EXIF data from original image
|
||||||
|
val originalExif =
|
||||||
|
context.contentResolver.openInputStream(imageUri)?.use { input ->
|
||||||
|
ExifInterface(input)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Compress and save the bitmap
|
||||||
|
val compressedBitmap = compressImage(originalBitmap)
|
||||||
|
FileOutputStream(outputFile).use { output ->
|
||||||
|
compressedBitmap.compress(Bitmap.CompressFormat.JPEG, IMAGE_QUALITY, output)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Copy EXIF data to the saved file
|
||||||
|
originalExif?.let { sourceExif ->
|
||||||
|
val destExif = ExifInterface(outputFile.absolutePath)
|
||||||
|
|
||||||
|
// Copy orientation and other important EXIF attributes
|
||||||
|
val orientationValue = sourceExif.getAttribute(ExifInterface.TAG_ORIENTATION)
|
||||||
|
orientationValue?.let { destExif.setAttribute(ExifInterface.TAG_ORIENTATION, it) }
|
||||||
|
|
||||||
|
// Copy other useful EXIF data
|
||||||
|
sourceExif.getAttribute(ExifInterface.TAG_DATETIME)?.let {
|
||||||
|
destExif.setAttribute(ExifInterface.TAG_DATETIME, it)
|
||||||
|
}
|
||||||
|
sourceExif.getAttribute(ExifInterface.TAG_GPS_LATITUDE)?.let {
|
||||||
|
destExif.setAttribute(ExifInterface.TAG_GPS_LATITUDE, it)
|
||||||
|
}
|
||||||
|
sourceExif.getAttribute(ExifInterface.TAG_GPS_LONGITUDE)?.let {
|
||||||
|
destExif.setAttribute(ExifInterface.TAG_GPS_LONGITUDE, it)
|
||||||
|
}
|
||||||
|
|
||||||
|
destExif.saveAttributes()
|
||||||
|
}
|
||||||
|
|
||||||
|
compressedBitmap.recycle()
|
||||||
|
true
|
||||||
|
} catch (e: Exception) {
|
||||||
|
e.printStackTrace()
|
||||||
|
false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Saves an image from a URI with compression */
|
||||||
fun saveImageFromUri(
|
fun saveImageFromUri(
|
||||||
context: Context,
|
context: Context,
|
||||||
imageUri: Uri,
|
imageUri: Uri,
|
||||||
@@ -42,10 +93,7 @@ object ImageUtils {
|
|||||||
}
|
}
|
||||||
?: return null
|
?: return null
|
||||||
|
|
||||||
val orientedBitmap = correctImageOrientation(context, imageUri, originalBitmap)
|
// Always require deterministic naming
|
||||||
val compressedBitmap = compressImage(orientedBitmap)
|
|
||||||
|
|
||||||
// Always require deterministic naming - no UUID fallback
|
|
||||||
require(problemId != null && imageIndex != null) {
|
require(problemId != null && imageIndex != null) {
|
||||||
"Problem ID and image index are required for deterministic image naming"
|
"Problem ID and image index are required for deterministic image naming"
|
||||||
}
|
}
|
||||||
@@ -53,15 +101,10 @@ object ImageUtils {
|
|||||||
val filename = ImageNamingUtils.generateImageFilename(problemId, imageIndex)
|
val filename = ImageNamingUtils.generateImageFilename(problemId, imageIndex)
|
||||||
val imageFile = File(getImagesDirectory(context), filename)
|
val imageFile = File(getImagesDirectory(context), filename)
|
||||||
|
|
||||||
FileOutputStream(imageFile).use { output ->
|
val success = saveImageWithExif(context, imageUri, originalBitmap, imageFile)
|
||||||
compressedBitmap.compress(Bitmap.CompressFormat.JPEG, IMAGE_QUALITY, output)
|
|
||||||
}
|
|
||||||
|
|
||||||
originalBitmap.recycle()
|
originalBitmap.recycle()
|
||||||
if (orientedBitmap != originalBitmap) {
|
|
||||||
orientedBitmap.recycle()
|
if (!success) return null
|
||||||
}
|
|
||||||
compressedBitmap.recycle()
|
|
||||||
|
|
||||||
"$IMAGES_DIR/$filename"
|
"$IMAGES_DIR/$filename"
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
@@ -221,23 +264,13 @@ object ImageUtils {
|
|||||||
MediaStore.Images.Media.getBitmap(context.contentResolver, imageUri)
|
MediaStore.Images.Media.getBitmap(context.contentResolver, imageUri)
|
||||||
?: return null
|
?: return null
|
||||||
|
|
||||||
val orientedBitmap = correctImageOrientation(context, imageUri, originalBitmap)
|
|
||||||
val compressedBitmap = compressImage(orientedBitmap)
|
|
||||||
|
|
||||||
val tempFilename = "temp_${UUID.randomUUID()}.jpg"
|
val tempFilename = "temp_${UUID.randomUUID()}.jpg"
|
||||||
val imageFile = File(getImagesDirectory(context), tempFilename)
|
val imageFile = File(getImagesDirectory(context), tempFilename)
|
||||||
|
|
||||||
FileOutputStream(imageFile).use { output ->
|
val success = saveImageWithExif(context, imageUri, originalBitmap, imageFile)
|
||||||
compressedBitmap.compress(Bitmap.CompressFormat.JPEG, IMAGE_QUALITY, output)
|
|
||||||
}
|
|
||||||
|
|
||||||
originalBitmap.recycle()
|
originalBitmap.recycle()
|
||||||
if (orientedBitmap != originalBitmap) {
|
|
||||||
orientedBitmap.recycle()
|
if (!success) return null
|
||||||
}
|
|
||||||
if (compressedBitmap != orientedBitmap) {
|
|
||||||
compressedBitmap.recycle()
|
|
||||||
}
|
|
||||||
|
|
||||||
tempFilename
|
tempFilename
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
@@ -315,21 +348,40 @@ object ImageUtils {
|
|||||||
filename: String
|
filename: String
|
||||||
): String? {
|
): String? {
|
||||||
return try {
|
return try {
|
||||||
val bitmap = BitmapFactory.decodeByteArray(imageData, 0, imageData.size) ?: return null
|
|
||||||
|
|
||||||
val compressedBitmap = compressImage(bitmap)
|
|
||||||
|
|
||||||
// Use the provided filename instead of generating a new UUID
|
|
||||||
val imageFile = File(getImagesDirectory(context), filename)
|
val imageFile = File(getImagesDirectory(context), filename)
|
||||||
|
|
||||||
|
// 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)
|
||||||
|
|
||||||
// Save compressed image
|
// Save compressed image
|
||||||
FileOutputStream(imageFile).use { output ->
|
FileOutputStream(imageFile).use { output ->
|
||||||
compressedBitmap.compress(Bitmap.CompressFormat.JPEG, IMAGE_QUALITY, output)
|
compressedBitmap.compress(Bitmap.CompressFormat.JPEG, IMAGE_QUALITY, output)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Clean up bitmaps
|
// 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()
|
bitmap.recycle()
|
||||||
compressedBitmap.recycle()
|
compressedBitmap.recycle()
|
||||||
|
} else {
|
||||||
|
// For smaller images, save raw data to preserve all EXIF information
|
||||||
|
FileOutputStream(imageFile).use { output -> output.write(imageData) }
|
||||||
|
}
|
||||||
|
|
||||||
// Return relative path
|
// Return relative path
|
||||||
"$IMAGES_DIR/$filename"
|
"$IMAGES_DIR/$filename"
|
||||||
|
|||||||
@@ -465,7 +465,7 @@
|
|||||||
CODE_SIGN_ENTITLEMENTS = OpenClimb/OpenClimb.entitlements;
|
CODE_SIGN_ENTITLEMENTS = OpenClimb/OpenClimb.entitlements;
|
||||||
CODE_SIGN_IDENTITY = "Apple Development";
|
CODE_SIGN_IDENTITY = "Apple Development";
|
||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
CURRENT_PROJECT_VERSION = 23;
|
CURRENT_PROJECT_VERSION = 24;
|
||||||
DEVELOPMENT_TEAM = 4BC9Y2LL4B;
|
DEVELOPMENT_TEAM = 4BC9Y2LL4B;
|
||||||
DRIVERKIT_DEPLOYMENT_TARGET = 24.6;
|
DRIVERKIT_DEPLOYMENT_TARGET = 24.6;
|
||||||
ENABLE_PREVIEWS = YES;
|
ENABLE_PREVIEWS = YES;
|
||||||
@@ -513,7 +513,7 @@
|
|||||||
CODE_SIGN_ENTITLEMENTS = OpenClimb/OpenClimb.entitlements;
|
CODE_SIGN_ENTITLEMENTS = OpenClimb/OpenClimb.entitlements;
|
||||||
CODE_SIGN_IDENTITY = "Apple Development";
|
CODE_SIGN_IDENTITY = "Apple Development";
|
||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
CURRENT_PROJECT_VERSION = 23;
|
CURRENT_PROJECT_VERSION = 24;
|
||||||
DEVELOPMENT_TEAM = 4BC9Y2LL4B;
|
DEVELOPMENT_TEAM = 4BC9Y2LL4B;
|
||||||
DRIVERKIT_DEPLOYMENT_TARGET = 24.6;
|
DRIVERKIT_DEPLOYMENT_TARGET = 24.6;
|
||||||
ENABLE_PREVIEWS = YES;
|
ENABLE_PREVIEWS = YES;
|
||||||
@@ -602,7 +602,7 @@
|
|||||||
ASSETCATALOG_COMPILER_WIDGET_BACKGROUND_COLOR_NAME = WidgetBackground;
|
ASSETCATALOG_COMPILER_WIDGET_BACKGROUND_COLOR_NAME = WidgetBackground;
|
||||||
CODE_SIGN_ENTITLEMENTS = SessionStatusLiveExtension.entitlements;
|
CODE_SIGN_ENTITLEMENTS = SessionStatusLiveExtension.entitlements;
|
||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
CURRENT_PROJECT_VERSION = 23;
|
CURRENT_PROJECT_VERSION = 24;
|
||||||
DEVELOPMENT_TEAM = 4BC9Y2LL4B;
|
DEVELOPMENT_TEAM = 4BC9Y2LL4B;
|
||||||
GENERATE_INFOPLIST_FILE = YES;
|
GENERATE_INFOPLIST_FILE = YES;
|
||||||
INFOPLIST_FILE = SessionStatusLive/Info.plist;
|
INFOPLIST_FILE = SessionStatusLive/Info.plist;
|
||||||
@@ -632,7 +632,7 @@
|
|||||||
ASSETCATALOG_COMPILER_WIDGET_BACKGROUND_COLOR_NAME = WidgetBackground;
|
ASSETCATALOG_COMPILER_WIDGET_BACKGROUND_COLOR_NAME = WidgetBackground;
|
||||||
CODE_SIGN_ENTITLEMENTS = SessionStatusLiveExtension.entitlements;
|
CODE_SIGN_ENTITLEMENTS = SessionStatusLiveExtension.entitlements;
|
||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
CURRENT_PROJECT_VERSION = 23;
|
CURRENT_PROJECT_VERSION = 24;
|
||||||
DEVELOPMENT_TEAM = 4BC9Y2LL4B;
|
DEVELOPMENT_TEAM = 4BC9Y2LL4B;
|
||||||
GENERATE_INFOPLIST_FILE = YES;
|
GENERATE_INFOPLIST_FILE = YES;
|
||||||
INFOPLIST_FILE = SessionStatusLive/Info.plist;
|
INFOPLIST_FILE = SessionStatusLive/Info.plist;
|
||||||
|
|||||||
Binary file not shown.
@@ -1,5 +1,6 @@
|
|||||||
import Foundation
|
import Foundation
|
||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
import UIKit
|
||||||
|
|
||||||
class ImageManager {
|
class ImageManager {
|
||||||
static let shared = ImageManager()
|
static let shared = ImageManager()
|
||||||
|
|||||||
154
ios/OpenClimb/Utils/OrientationAwareImage.swift
Normal file
154
ios/OpenClimb/Utils/OrientationAwareImage.swift
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1308,131 +1308,19 @@ struct EditAttemptView: View {
|
|||||||
|
|
||||||
struct ProblemSelectionImageView: View {
|
struct ProblemSelectionImageView: View {
|
||||||
let imagePath: String
|
let imagePath: String
|
||||||
@State private var uiImage: UIImage?
|
|
||||||
@State private var isLoading = true
|
|
||||||
@State private var hasFailed = false
|
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
Group {
|
OrientationAwareImage.fill(imagePath: imagePath)
|
||||||
if let uiImage = uiImage {
|
|
||||||
Image(uiImage: uiImage)
|
|
||||||
.resizable()
|
|
||||||
.aspectRatio(contentMode: .fill)
|
|
||||||
.frame(height: 80)
|
.frame(height: 80)
|
||||||
.clipped()
|
.clipped()
|
||||||
.cornerRadius(8)
|
.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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
struct ProblemSelectionImageFullView: View {
|
struct ProblemSelectionImageFullView: View {
|
||||||
let imagePath: String
|
let imagePath: String
|
||||||
@State private var uiImage: UIImage?
|
|
||||||
@State private var isLoading = true
|
|
||||||
@State private var hasFailed = false
|
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
Group {
|
OrientationAwareImage.fit(imagePath: imagePath)
|
||||||
if let uiImage = uiImage {
|
|
||||||
Image(uiImage: uiImage)
|
|
||||||
.resizable()
|
|
||||||
.aspectRatio(contentMode: .fit)
|
|
||||||
} else if hasFailed {
|
|
||||||
RoundedRectangle(cornerRadius: 12)
|
|
||||||
.fill(.gray.opacity(0.2))
|
|
||||||
.frame(height: 250)
|
|
||||||
.overlay {
|
|
||||||
VStack(spacing: 8) {
|
|
||||||
Image(systemName: "photo")
|
|
||||||
.foregroundColor(.gray)
|
|
||||||
.font(.largeTitle)
|
|
||||||
Text("Image not available")
|
|
||||||
.foregroundColor(.gray)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
RoundedRectangle(cornerRadius: 12)
|
|
||||||
.fill(.gray.opacity(0.3))
|
|
||||||
.frame(height: 250)
|
|
||||||
.overlay {
|
|
||||||
ProgressView()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.onAppear {
|
|
||||||
loadImage()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private func loadImage() {
|
|
||||||
guard !imagePath.isEmpty else {
|
|
||||||
hasFailed = true
|
|
||||||
isLoading = false
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
DispatchQueue.global(qos: .userInitiated).async {
|
|
||||||
if let data = ImageManager.shared.loadImageData(fromPath: imagePath),
|
|
||||||
let image = UIImage(data: data)
|
|
||||||
{
|
|
||||||
DispatchQueue.main.async {
|
|
||||||
self.uiImage = image
|
|
||||||
self.isLoading = false
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
DispatchQueue.main.async {
|
|
||||||
self.hasFailed = true
|
|
||||||
self.isLoading = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -443,132 +443,20 @@ struct ImageViewerView: View {
|
|||||||
|
|
||||||
struct ProblemDetailImageView: View {
|
struct ProblemDetailImageView: View {
|
||||||
let imagePath: String
|
let imagePath: String
|
||||||
@State private var uiImage: UIImage?
|
|
||||||
@State private var isLoading = true
|
|
||||||
@State private var hasFailed = false
|
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
Group {
|
OrientationAwareImage.fill(imagePath: imagePath)
|
||||||
if let uiImage = uiImage {
|
|
||||||
Image(uiImage: uiImage)
|
|
||||||
.resizable()
|
|
||||||
.aspectRatio(contentMode: .fill)
|
|
||||||
.frame(width: 120, height: 120)
|
.frame(width: 120, height: 120)
|
||||||
.clipped()
|
.clipped()
|
||||||
.cornerRadius(12)
|
.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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
struct ProblemDetailImageFullView: View {
|
struct ProblemDetailImageFullView: View {
|
||||||
let imagePath: String
|
let imagePath: String
|
||||||
@State private var uiImage: UIImage?
|
|
||||||
@State private var isLoading = true
|
|
||||||
@State private var hasFailed = false
|
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
Group {
|
OrientationAwareImage.fit(imagePath: imagePath)
|
||||||
if let uiImage = uiImage {
|
|
||||||
Image(uiImage: uiImage)
|
|
||||||
.resizable()
|
|
||||||
.aspectRatio(contentMode: .fit)
|
|
||||||
} else if hasFailed {
|
|
||||||
Rectangle()
|
|
||||||
.fill(.gray.opacity(0.2))
|
|
||||||
.frame(height: 250)
|
|
||||||
.overlay {
|
|
||||||
VStack(spacing: 8) {
|
|
||||||
Image(systemName: "photo")
|
|
||||||
.foregroundColor(.gray)
|
|
||||||
.font(.largeTitle)
|
|
||||||
Text("Image not available")
|
|
||||||
.foregroundColor(.gray)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
Rectangle()
|
|
||||||
.fill(.gray.opacity(0.3))
|
|
||||||
.frame(height: 250)
|
|
||||||
.overlay {
|
|
||||||
ProgressView()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.onAppear {
|
|
||||||
loadImage()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private func loadImage() {
|
|
||||||
guard !imagePath.isEmpty else {
|
|
||||||
hasFailed = true
|
|
||||||
isLoading = false
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -480,81 +480,12 @@ struct EmptyProblemsView: View {
|
|||||||
|
|
||||||
struct ProblemImageView: View {
|
struct ProblemImageView: View {
|
||||||
let imagePath: String
|
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 {
|
var body: some View {
|
||||||
Group {
|
OrientationAwareImage.fill(imagePath: imagePath)
|
||||||
if let uiImage = uiImage {
|
|
||||||
Image(uiImage: uiImage)
|
|
||||||
.resizable()
|
|
||||||
.aspectRatio(contentMode: .fill)
|
|
||||||
.frame(width: 60, height: 60)
|
.frame(width: 60, height: 60)
|
||||||
.clipped()
|
.clipped()
|
||||||
.cornerRadius(8)
|
.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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user