[Android] 1.9.1 - EXIF Fixes
This commit is contained in:
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -3,7 +3,6 @@ package com.atridad.openclimb.ui.components
|
||||
import androidx.compose.foundation.ExperimentalFoundationApi
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.gestures.detectTransformGestures
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.lazy.LazyRow
|
||||
import androidx.compose.foundation.lazy.itemsIndexed
|
||||
@@ -20,140 +19,114 @@ import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.graphicsLayer
|
||||
import androidx.compose.ui.input.pointer.pointerInput
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.window.Dialog
|
||||
import androidx.compose.ui.window.DialogProperties
|
||||
import coil.compose.AsyncImage
|
||||
import com.atridad.openclimb.utils.ImageUtils
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
@OptIn(ExperimentalFoundationApi::class)
|
||||
@Composable
|
||||
fun FullscreenImageViewer(
|
||||
imagePaths: List<String>,
|
||||
initialIndex: Int = 0,
|
||||
onDismiss: () -> Unit
|
||||
) {
|
||||
fun FullscreenImageViewer(imagePaths: List<String>, initialIndex: Int = 0, onDismiss: () -> Unit) {
|
||||
val context = LocalContext.current
|
||||
val pagerState = rememberPagerState(
|
||||
initialPage = initialIndex,
|
||||
pageCount = { imagePaths.size }
|
||||
)
|
||||
val pagerState = rememberPagerState(initialPage = initialIndex, pageCount = { imagePaths.size })
|
||||
val thumbnailListState = rememberLazyListState()
|
||||
val coroutineScope = rememberCoroutineScope()
|
||||
|
||||
|
||||
// Auto-scroll thumbnail list to center current image
|
||||
LaunchedEffect(pagerState.currentPage) {
|
||||
thumbnailListState.animateScrollToItem(
|
||||
index = pagerState.currentPage,
|
||||
scrollOffset = -200
|
||||
)
|
||||
thumbnailListState.animateScrollToItem(index = pagerState.currentPage, scrollOffset = -200)
|
||||
}
|
||||
|
||||
|
||||
Dialog(
|
||||
onDismissRequest = onDismiss,
|
||||
properties = DialogProperties(
|
||||
usePlatformDefaultWidth = false,
|
||||
decorFitsSystemWindows = false
|
||||
)
|
||||
onDismissRequest = onDismiss,
|
||||
properties =
|
||||
DialogProperties(
|
||||
usePlatformDefaultWidth = false,
|
||||
decorFitsSystemWindows = false
|
||||
)
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.background(Color.Black)
|
||||
) {
|
||||
Box(modifier = Modifier.fillMaxSize().background(Color.Black)) {
|
||||
// Main image pager
|
||||
HorizontalPager(
|
||||
state = pagerState,
|
||||
modifier = Modifier.fillMaxSize()
|
||||
) { page ->
|
||||
ZoomableImage(
|
||||
imagePath = imagePaths[page],
|
||||
modifier = Modifier.fillMaxSize()
|
||||
HorizontalPager(state = pagerState, modifier = Modifier.fillMaxSize()) { page ->
|
||||
OrientationAwareImage(
|
||||
imagePath = imagePaths[page],
|
||||
contentDescription = "Full screen image",
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
contentScale = ContentScale.Fit
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
// Close button
|
||||
IconButton(
|
||||
onClick = onDismiss,
|
||||
modifier = Modifier
|
||||
.align(Alignment.TopEnd)
|
||||
.padding(16.dp)
|
||||
.background(
|
||||
Color.Black.copy(alpha = 0.5f),
|
||||
CircleShape
|
||||
)
|
||||
) {
|
||||
Icon(
|
||||
Icons.Default.Close,
|
||||
contentDescription = "Close",
|
||||
tint = Color.White
|
||||
)
|
||||
}
|
||||
|
||||
onClick = onDismiss,
|
||||
modifier =
|
||||
Modifier.align(Alignment.TopEnd)
|
||||
.padding(16.dp)
|
||||
.background(Color.Black.copy(alpha = 0.5f), CircleShape)
|
||||
) { Icon(Icons.Default.Close, contentDescription = "Close", tint = Color.White) }
|
||||
|
||||
// Image counter
|
||||
if (imagePaths.size > 1) {
|
||||
Card(
|
||||
modifier = Modifier
|
||||
.align(Alignment.TopCenter)
|
||||
.padding(16.dp),
|
||||
colors = CardDefaults.cardColors(
|
||||
containerColor = Color.Black.copy(alpha = 0.7f)
|
||||
)
|
||||
modifier = Modifier.align(Alignment.TopCenter).padding(16.dp),
|
||||
colors =
|
||||
CardDefaults.cardColors(
|
||||
containerColor = Color.Black.copy(alpha = 0.7f)
|
||||
)
|
||||
) {
|
||||
Text(
|
||||
text = "${pagerState.currentPage + 1} / ${imagePaths.size}",
|
||||
color = Color.White,
|
||||
modifier = Modifier.padding(horizontal = 12.dp, vertical = 6.dp)
|
||||
text = "${pagerState.currentPage + 1} / ${imagePaths.size}",
|
||||
color = Color.White,
|
||||
modifier = Modifier.padding(horizontal = 12.dp, vertical = 6.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// Thumbnail strip (if multiple images)
|
||||
if (imagePaths.size > 1) {
|
||||
Card(
|
||||
modifier = Modifier
|
||||
.align(Alignment.BottomCenter)
|
||||
.fillMaxWidth()
|
||||
.padding(16.dp),
|
||||
colors = CardDefaults.cardColors(
|
||||
containerColor = Color.Black.copy(alpha = 0.7f)
|
||||
)
|
||||
modifier =
|
||||
Modifier.align(Alignment.BottomCenter)
|
||||
.fillMaxWidth()
|
||||
.padding(16.dp),
|
||||
colors =
|
||||
CardDefaults.cardColors(
|
||||
containerColor = Color.Black.copy(alpha = 0.7f)
|
||||
)
|
||||
) {
|
||||
LazyRow(
|
||||
state = thumbnailListState,
|
||||
modifier = Modifier.padding(8.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
contentPadding = PaddingValues(horizontal = 8.dp)
|
||||
state = thumbnailListState,
|
||||
modifier = Modifier.padding(8.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
contentPadding = PaddingValues(horizontal = 8.dp)
|
||||
) {
|
||||
itemsIndexed(imagePaths) { index, imagePath ->
|
||||
val imageFile = ImageUtils.getImageFile(context, imagePath)
|
||||
val isSelected = index == pagerState.currentPage
|
||||
|
||||
AsyncImage(
|
||||
model = imageFile,
|
||||
contentDescription = "Thumbnail ${index + 1}",
|
||||
modifier = Modifier
|
||||
.size(60.dp)
|
||||
.clip(RoundedCornerShape(8.dp))
|
||||
.clickable {
|
||||
coroutineScope.launch {
|
||||
pagerState.animateScrollToPage(index)
|
||||
}
|
||||
}
|
||||
.then(
|
||||
if (isSelected) {
|
||||
Modifier.background(
|
||||
Color.White.copy(alpha = 0.3f),
|
||||
RoundedCornerShape(8.dp)
|
||||
)
|
||||
} else Modifier
|
||||
),
|
||||
contentScale = ContentScale.Crop
|
||||
|
||||
OrientationAwareImage(
|
||||
imagePath = imagePath,
|
||||
contentDescription = "Thumbnail ${index + 1}",
|
||||
modifier =
|
||||
Modifier.size(60.dp)
|
||||
.clip(RoundedCornerShape(8.dp))
|
||||
.clickable {
|
||||
coroutineScope.launch {
|
||||
pagerState.animateScrollToPage(index)
|
||||
}
|
||||
}
|
||||
.then(
|
||||
if (isSelected) {
|
||||
Modifier.background(
|
||||
Color.White.copy(
|
||||
alpha = 0.3f
|
||||
),
|
||||
RoundedCornerShape(8.dp)
|
||||
)
|
||||
} else Modifier
|
||||
),
|
||||
contentScale = ContentScale.Crop
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -162,48 +135,3 @@ fun FullscreenImageViewer(
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ZoomableImage(
|
||||
imagePath: String,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
val imageFile = ImageUtils.getImageFile(context, imagePath)
|
||||
|
||||
var scale by remember { mutableFloatStateOf(1f) }
|
||||
var offsetX by remember { mutableFloatStateOf(0f) }
|
||||
var offsetY by remember { mutableFloatStateOf(0f) }
|
||||
|
||||
Box(
|
||||
modifier = modifier
|
||||
.pointerInput(Unit) {
|
||||
detectTransformGestures(
|
||||
onGesture = { _, pan, zoom, _ ->
|
||||
scale = (scale * zoom).coerceIn(0.5f, 5f)
|
||||
|
||||
val maxOffsetX = (size.width * (scale - 1)) / 2
|
||||
val maxOffsetY = (size.height * (scale - 1)) / 2
|
||||
|
||||
offsetX = (offsetX + pan.x).coerceIn(-maxOffsetX, maxOffsetX)
|
||||
offsetY = (offsetY + pan.y).coerceIn(-maxOffsetY, maxOffsetY)
|
||||
}
|
||||
)
|
||||
},
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
AsyncImage(
|
||||
model = imageFile,
|
||||
contentDescription = "Full screen image",
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.graphicsLayer(
|
||||
scaleX = scale,
|
||||
scaleY = scale,
|
||||
translationX = offsetX,
|
||||
translationY = offsetY
|
||||
),
|
||||
contentScale = ContentScale.Fit
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,36 +12,29 @@ import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.unit.dp
|
||||
import coil.compose.AsyncImage
|
||||
import com.atridad.openclimb.utils.ImageUtils
|
||||
|
||||
@Composable
|
||||
fun ImageDisplay(
|
||||
imagePaths: List<String>,
|
||||
modifier: Modifier = Modifier,
|
||||
imageSize: Int = 120,
|
||||
onImageClick: ((Int) -> Unit)? = null
|
||||
imagePaths: List<String>,
|
||||
modifier: Modifier = Modifier,
|
||||
imageSize: Int = 120,
|
||||
onImageClick: ((Int) -> Unit)? = null
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
|
||||
|
||||
if (imagePaths.isNotEmpty()) {
|
||||
LazyRow(
|
||||
modifier = modifier,
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp)
|
||||
) {
|
||||
LazyRow(modifier = modifier, horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
itemsIndexed(imagePaths) { index, imagePath ->
|
||||
val imageFile = ImageUtils.getImageFile(context, imagePath)
|
||||
|
||||
AsyncImage(
|
||||
model = imageFile,
|
||||
contentDescription = "Problem photo",
|
||||
modifier = Modifier
|
||||
.size(imageSize.dp)
|
||||
.clip(RoundedCornerShape(8.dp))
|
||||
.clickable(enabled = onImageClick != null) {
|
||||
onImageClick?.invoke(index)
|
||||
},
|
||||
contentScale = ContentScale.Crop
|
||||
OrientationAwareImage(
|
||||
imagePath = imagePath,
|
||||
contentDescription = "Problem photo",
|
||||
modifier =
|
||||
Modifier.size(imageSize.dp)
|
||||
.clip(RoundedCornerShape(8.dp))
|
||||
.clickable(enabled = onImageClick != null) {
|
||||
onImageClick?.invoke(index)
|
||||
},
|
||||
contentScale = ContentScale.Crop
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -50,26 +43,22 @@ fun ImageDisplay(
|
||||
|
||||
@Composable
|
||||
fun ImageDisplaySection(
|
||||
imagePaths: List<String>,
|
||||
modifier: Modifier = Modifier,
|
||||
title: String = "Photos",
|
||||
onImageClick: ((Int) -> Unit)? = null
|
||||
imagePaths: List<String>,
|
||||
modifier: Modifier = Modifier,
|
||||
title: String = "Photos",
|
||||
onImageClick: ((Int) -> Unit)? = null
|
||||
) {
|
||||
if (imagePaths.isNotEmpty()) {
|
||||
Column(modifier = modifier) {
|
||||
Text(
|
||||
text = title,
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
color = MaterialTheme.colorScheme.onSurface
|
||||
text = title,
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
color = MaterialTheme.colorScheme.onSurface
|
||||
)
|
||||
|
||||
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
|
||||
ImageDisplay(
|
||||
imagePaths = imagePaths,
|
||||
imageSize = 120,
|
||||
onImageClick = onImageClick
|
||||
)
|
||||
|
||||
ImageDisplay(imagePaths = imagePaths, imageSize = 120, onImageClick = onImageClick)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 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)
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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;
|
||||
|
||||
Binary file not shown.
@@ -1,5 +1,6 @@
|
||||
import Foundation
|
||||
import SwiftUI
|
||||
import UIKit
|
||||
|
||||
class 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 {
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user