Added photos and ZIP exports
This commit is contained in:
@@ -0,0 +1,209 @@
|
|||||||
|
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
|
||||||
|
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||||
|
import androidx.compose.foundation.pager.HorizontalPager
|
||||||
|
import androidx.compose.foundation.pager.rememberPagerState
|
||||||
|
import androidx.compose.foundation.shape.CircleShape
|
||||||
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.filled.Close
|
||||||
|
import androidx.compose.material3.*
|
||||||
|
import androidx.compose.runtime.*
|
||||||
|
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
|
||||||
|
) {
|
||||||
|
val context = LocalContext.current
|
||||||
|
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 // Center the item
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
Dialog(
|
||||||
|
onDismissRequest = onDismiss,
|
||||||
|
properties = DialogProperties(
|
||||||
|
usePlatformDefaultWidth = false,
|
||||||
|
decorFitsSystemWindows = false
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.background(Color.Black)
|
||||||
|
) {
|
||||||
|
// Main image pager
|
||||||
|
HorizontalPager(
|
||||||
|
state = pagerState,
|
||||||
|
modifier = Modifier.fillMaxSize()
|
||||||
|
) { page ->
|
||||||
|
ZoomableImage(
|
||||||
|
imagePath = imagePaths[page],
|
||||||
|
modifier = Modifier.fillMaxSize()
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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)
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
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)
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
LazyRow(
|
||||||
|
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
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@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
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,76 @@
|
|||||||
|
package com.atridad.openclimb.ui.components
|
||||||
|
|
||||||
|
import androidx.compose.foundation.clickable
|
||||||
|
import androidx.compose.foundation.layout.*
|
||||||
|
import androidx.compose.foundation.lazy.LazyRow
|
||||||
|
import androidx.compose.foundation.lazy.itemsIndexed
|
||||||
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
|
import androidx.compose.material3.*
|
||||||
|
import androidx.compose.runtime.*
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
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
|
||||||
|
) {
|
||||||
|
val context = LocalContext.current
|
||||||
|
|
||||||
|
if (imagePaths.isNotEmpty()) {
|
||||||
|
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
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun ImageDisplaySection(
|
||||||
|
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
|
||||||
|
)
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.height(8.dp))
|
||||||
|
|
||||||
|
ImageDisplay(
|
||||||
|
imagePaths = imagePaths,
|
||||||
|
imageSize = 120,
|
||||||
|
onImageClick = onImageClick
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,184 @@
|
|||||||
|
package com.atridad.openclimb.ui.components
|
||||||
|
|
||||||
|
import android.net.Uri
|
||||||
|
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||||
|
import androidx.activity.result.contract.ActivityResultContracts
|
||||||
|
import androidx.compose.foundation.layout.*
|
||||||
|
import androidx.compose.foundation.lazy.LazyRow
|
||||||
|
import androidx.compose.foundation.lazy.items
|
||||||
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.filled.Add
|
||||||
|
import androidx.compose.material.icons.filled.Close
|
||||||
|
import androidx.compose.material3.*
|
||||||
|
import androidx.compose.runtime.*
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
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
|
||||||
|
import java.io.File
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun ImagePicker(
|
||||||
|
imageUris: List<String>,
|
||||||
|
onImagesChanged: (List<String>) -> Unit,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
maxImages: Int = 5
|
||||||
|
) {
|
||||||
|
val context = LocalContext.current
|
||||||
|
var tempImageUris by remember { mutableStateOf(imageUris) }
|
||||||
|
|
||||||
|
// Image picker launcher
|
||||||
|
val imagePickerLauncher = rememberLauncherForActivityResult(
|
||||||
|
contract = ActivityResultContracts.GetMultipleContents()
|
||||||
|
) { uris ->
|
||||||
|
if (uris.isNotEmpty()) {
|
||||||
|
val currentCount = tempImageUris.size
|
||||||
|
val remainingSlots = maxImages - currentCount
|
||||||
|
val urisToProcess = uris.take(remainingSlots)
|
||||||
|
|
||||||
|
// Process each selected image
|
||||||
|
val newImagePaths = mutableListOf<String>()
|
||||||
|
urisToProcess.forEach { uri ->
|
||||||
|
val imagePath = ImageUtils.saveImageFromUri(context, uri)
|
||||||
|
if (imagePath != null) {
|
||||||
|
newImagePaths.add(imagePath)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (newImagePaths.isNotEmpty()) {
|
||||||
|
val updatedUris = tempImageUris + newImagePaths
|
||||||
|
tempImageUris = updatedUris
|
||||||
|
onImagesChanged(updatedUris)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Column(modifier = modifier) {
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
horizontalArrangement = Arrangement.SpaceBetween,
|
||||||
|
verticalAlignment = Alignment.CenterVertically
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = "Photos (${tempImageUris.size}/$maxImages)",
|
||||||
|
style = MaterialTheme.typography.titleMedium
|
||||||
|
)
|
||||||
|
|
||||||
|
if (tempImageUris.size < maxImages) {
|
||||||
|
TextButton(
|
||||||
|
onClick = {
|
||||||
|
imagePickerLauncher.launch("image/*")
|
||||||
|
}
|
||||||
|
) {
|
||||||
|
Icon(Icons.Default.Add, contentDescription = null, modifier = Modifier.size(16.dp))
|
||||||
|
Spacer(modifier = Modifier.width(4.dp))
|
||||||
|
Text("Add Photos")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (tempImageUris.isNotEmpty()) {
|
||||||
|
Spacer(modifier = Modifier.height(8.dp))
|
||||||
|
|
||||||
|
LazyRow(
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(8.dp)
|
||||||
|
) {
|
||||||
|
items(tempImageUris) { imagePath ->
|
||||||
|
ImageItem(
|
||||||
|
imagePath = imagePath,
|
||||||
|
onRemove = {
|
||||||
|
val updatedUris = tempImageUris.filter { it != imagePath }
|
||||||
|
tempImageUris = updatedUris
|
||||||
|
onImagesChanged(updatedUris)
|
||||||
|
|
||||||
|
// Delete the image file
|
||||||
|
ImageUtils.deleteImage(context, imagePath)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
Spacer(modifier = Modifier.height(8.dp))
|
||||||
|
Card(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.height(100.dp),
|
||||||
|
colors = CardDefaults.cardColors(
|
||||||
|
containerColor = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.3f)
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
Box(
|
||||||
|
modifier = Modifier.fillMaxSize(),
|
||||||
|
contentAlignment = Alignment.Center
|
||||||
|
) {
|
||||||
|
Column(
|
||||||
|
horizontalAlignment = Alignment.CenterHorizontally
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
Icons.Default.Add,
|
||||||
|
contentDescription = null,
|
||||||
|
tint = MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
|
)
|
||||||
|
Spacer(modifier = Modifier.height(4.dp))
|
||||||
|
Text(
|
||||||
|
text = "Add photos of this problem",
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun ImageItem(
|
||||||
|
imagePath: String,
|
||||||
|
onRemove: () -> Unit,
|
||||||
|
modifier: Modifier = Modifier
|
||||||
|
) {
|
||||||
|
val context = LocalContext.current
|
||||||
|
val imageFile = ImageUtils.getImageFile(context, imagePath)
|
||||||
|
|
||||||
|
Box(
|
||||||
|
modifier = modifier.size(80.dp)
|
||||||
|
) {
|
||||||
|
AsyncImage(
|
||||||
|
model = imageFile,
|
||||||
|
contentDescription = "Problem photo",
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.clip(RoundedCornerShape(8.dp)),
|
||||||
|
contentScale = ContentScale.Crop
|
||||||
|
)
|
||||||
|
|
||||||
|
IconButton(
|
||||||
|
onClick = onRemove,
|
||||||
|
modifier = Modifier
|
||||||
|
.align(Alignment.TopEnd)
|
||||||
|
.size(24.dp)
|
||||||
|
) {
|
||||||
|
Card(
|
||||||
|
shape = RoundedCornerShape(12.dp),
|
||||||
|
colors = CardDefaults.cardColors(
|
||||||
|
containerColor = MaterialTheme.colorScheme.errorContainer
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
Icons.Default.Close,
|
||||||
|
contentDescription = "Remove photo",
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.padding(2.dp),
|
||||||
|
tint = MaterialTheme.colorScheme.onErrorContainer
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
196
app/src/main/java/com/atridad/openclimb/utils/ImageUtils.kt
Normal file
196
app/src/main/java/com/atridad/openclimb/utils/ImageUtils.kt
Normal file
@@ -0,0 +1,196 @@
|
|||||||
|
package com.atridad.openclimb.utils
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import android.graphics.Bitmap
|
||||||
|
import android.graphics.BitmapFactory
|
||||||
|
import android.net.Uri
|
||||||
|
import java.io.File
|
||||||
|
import java.io.FileOutputStream
|
||||||
|
import java.io.IOException
|
||||||
|
import java.util.UUID
|
||||||
|
|
||||||
|
object ImageUtils {
|
||||||
|
|
||||||
|
private const val IMAGES_DIR = "problem_images"
|
||||||
|
private const val MAX_IMAGE_SIZE = 1024
|
||||||
|
private const val IMAGE_QUALITY = 85
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates the images directory if it doesn't exist
|
||||||
|
*/
|
||||||
|
private fun getImagesDirectory(context: Context): File {
|
||||||
|
val imagesDir = File(context.filesDir, IMAGES_DIR)
|
||||||
|
if (!imagesDir.exists()) {
|
||||||
|
imagesDir.mkdirs()
|
||||||
|
}
|
||||||
|
return imagesDir
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Saves an image from URI to app's private storage with compression
|
||||||
|
* @param context Android context
|
||||||
|
* @param imageUri URI of the image to save
|
||||||
|
* @return The relative file path if successful, null otherwise
|
||||||
|
*/
|
||||||
|
fun saveImageFromUri(context: Context, imageUri: Uri): String? {
|
||||||
|
return try {
|
||||||
|
val inputStream = context.contentResolver.openInputStream(imageUri)
|
||||||
|
inputStream?.use { input ->
|
||||||
|
// Decode and compress the image
|
||||||
|
val originalBitmap = BitmapFactory.decodeStream(input)
|
||||||
|
val compressedBitmap = compressImage(originalBitmap)
|
||||||
|
|
||||||
|
// Generate unique filename
|
||||||
|
val filename = "${UUID.randomUUID()}.jpg"
|
||||||
|
val imageFile = File(getImagesDirectory(context), filename)
|
||||||
|
|
||||||
|
// Save compressed image
|
||||||
|
FileOutputStream(imageFile).use { output ->
|
||||||
|
compressedBitmap.compress(Bitmap.CompressFormat.JPEG, IMAGE_QUALITY, output)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clean up bitmaps
|
||||||
|
originalBitmap.recycle()
|
||||||
|
compressedBitmap.recycle()
|
||||||
|
|
||||||
|
// Return relative path
|
||||||
|
"$IMAGES_DIR/$filename"
|
||||||
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
e.printStackTrace()
|
||||||
|
null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Compresses and resizes an image bitmap
|
||||||
|
*/
|
||||||
|
private fun compressImage(original: Bitmap): Bitmap {
|
||||||
|
val width = original.width
|
||||||
|
val height = original.height
|
||||||
|
|
||||||
|
// Calculate the scaling factor
|
||||||
|
val scaleFactor = if (width > height) {
|
||||||
|
if (width > MAX_IMAGE_SIZE) MAX_IMAGE_SIZE.toFloat() / width else 1f
|
||||||
|
} else {
|
||||||
|
if (height > MAX_IMAGE_SIZE) MAX_IMAGE_SIZE.toFloat() / height else 1f
|
||||||
|
}
|
||||||
|
|
||||||
|
return if (scaleFactor < 1f) {
|
||||||
|
val newWidth = (width * scaleFactor).toInt()
|
||||||
|
val newHeight = (height * scaleFactor).toInt()
|
||||||
|
Bitmap.createScaledBitmap(original, newWidth, newHeight, true)
|
||||||
|
} else {
|
||||||
|
original
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gets the full file path for an image
|
||||||
|
* @param context Android context
|
||||||
|
* @param relativePath The relative path returned by saveImageFromUri
|
||||||
|
* @return Full file path
|
||||||
|
*/
|
||||||
|
fun getImageFile(context: Context, relativePath: String): File {
|
||||||
|
return File(context.filesDir, relativePath)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Deletes an image file
|
||||||
|
* @param context Android context
|
||||||
|
* @param relativePath The relative path of the image to delete
|
||||||
|
* @return true if deleted successfully, false otherwise
|
||||||
|
*/
|
||||||
|
fun deleteImage(context: Context, relativePath: String): Boolean {
|
||||||
|
return try {
|
||||||
|
val file = getImageFile(context, relativePath)
|
||||||
|
file.delete()
|
||||||
|
} catch (e: Exception) {
|
||||||
|
e.printStackTrace()
|
||||||
|
false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Copies an image file to export directory
|
||||||
|
* @param context Android context
|
||||||
|
* @param relativePath The relative path of the image
|
||||||
|
* @param exportDir The directory to copy to
|
||||||
|
* @return The filename in the export directory, null if failed
|
||||||
|
*/
|
||||||
|
fun copyImageForExport(context: Context, relativePath: String, exportDir: File): String? {
|
||||||
|
return try {
|
||||||
|
val sourceFile = getImageFile(context, relativePath)
|
||||||
|
if (!sourceFile.exists()) return null
|
||||||
|
|
||||||
|
val filename = sourceFile.name
|
||||||
|
val destFile = File(exportDir, filename)
|
||||||
|
|
||||||
|
sourceFile.copyTo(destFile, overwrite = true)
|
||||||
|
filename
|
||||||
|
} catch (e: Exception) {
|
||||||
|
e.printStackTrace()
|
||||||
|
null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Imports an image file from the import directory
|
||||||
|
* @param context Android context
|
||||||
|
* @param sourceFile The source image file to import
|
||||||
|
* @return The relative path in app storage, null if failed
|
||||||
|
*/
|
||||||
|
fun importImageFile(context: Context, sourceFile: File): String? {
|
||||||
|
return try {
|
||||||
|
if (!sourceFile.exists()) return null
|
||||||
|
|
||||||
|
// Generate new filename to avoid conflicts
|
||||||
|
val extension = sourceFile.extension.ifEmpty { "jpg" }
|
||||||
|
val filename = "${UUID.randomUUID()}.$extension"
|
||||||
|
val destFile = File(getImagesDirectory(context), filename)
|
||||||
|
|
||||||
|
sourceFile.copyTo(destFile, overwrite = true)
|
||||||
|
"$IMAGES_DIR/$filename"
|
||||||
|
} catch (e: Exception) {
|
||||||
|
e.printStackTrace()
|
||||||
|
null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gets all image files in the images directory
|
||||||
|
* @param context Android context
|
||||||
|
* @return List of relative paths for all images
|
||||||
|
*/
|
||||||
|
fun getAllImages(context: Context): List<String> {
|
||||||
|
return try {
|
||||||
|
val imagesDir = getImagesDirectory(context)
|
||||||
|
imagesDir.listFiles()?.mapNotNull { file ->
|
||||||
|
if (file.isFile && (file.extension == "jpg" || file.extension == "jpeg" || file.extension == "png")) {
|
||||||
|
"$IMAGES_DIR/${file.name}"
|
||||||
|
} else null
|
||||||
|
} ?: emptyList()
|
||||||
|
} catch (e: Exception) {
|
||||||
|
e.printStackTrace()
|
||||||
|
emptyList()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Cleans up orphaned images that are not referenced by any problems
|
||||||
|
* @param context Android context
|
||||||
|
* @param referencedPaths Set of image paths that are still being used
|
||||||
|
*/
|
||||||
|
fun cleanupOrphanedImages(context: Context, referencedPaths: Set<String>) {
|
||||||
|
try {
|
||||||
|
val allImages = getAllImages(context)
|
||||||
|
val orphanedImages = allImages.filter { it !in referencedPaths }
|
||||||
|
|
||||||
|
orphanedImages.forEach { path ->
|
||||||
|
deleteImage(context, path)
|
||||||
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
e.printStackTrace()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,207 @@
|
|||||||
|
package com.atridad.openclimb.utils
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import kotlinx.serialization.encodeToString
|
||||||
|
import kotlinx.serialization.json.Json
|
||||||
|
import java.io.File
|
||||||
|
import java.io.FileInputStream
|
||||||
|
import java.io.FileOutputStream
|
||||||
|
import java.io.IOException
|
||||||
|
import java.time.LocalDateTime
|
||||||
|
import java.util.zip.ZipEntry
|
||||||
|
import java.util.zip.ZipInputStream
|
||||||
|
import java.util.zip.ZipOutputStream
|
||||||
|
|
||||||
|
object ZipExportImportUtils {
|
||||||
|
|
||||||
|
private const val DATA_JSON_FILENAME = "data.json"
|
||||||
|
private const val IMAGES_DIR_NAME = "images"
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates a ZIP file containing the JSON data and all referenced images
|
||||||
|
* @param context Android context
|
||||||
|
* @param exportData The data to export (should be serializable)
|
||||||
|
* @param referencedImagePaths Set of image paths referenced in the data
|
||||||
|
* @param directory Optional directory to save to, uses default if null
|
||||||
|
* @return The created ZIP file
|
||||||
|
*/
|
||||||
|
fun createExportZip(
|
||||||
|
context: Context,
|
||||||
|
exportData: com.atridad.openclimb.data.repository.ClimbDataExport,
|
||||||
|
referencedImagePaths: Set<String>,
|
||||||
|
directory: File? = null
|
||||||
|
): File {
|
||||||
|
val exportDir = directory ?: File(context.getExternalFilesDir(android.os.Environment.DIRECTORY_DOCUMENTS), "OpenClimb")
|
||||||
|
if (!exportDir.exists()) {
|
||||||
|
exportDir.mkdirs()
|
||||||
|
}
|
||||||
|
|
||||||
|
val timestamp = LocalDateTime.now().toString().replace(":", "-").replace(".", "-")
|
||||||
|
val zipFile = File(exportDir, "openclimb_export_$timestamp.zip")
|
||||||
|
|
||||||
|
ZipOutputStream(FileOutputStream(zipFile)).use { zipOut ->
|
||||||
|
// Add JSON data file
|
||||||
|
val json = Json { prettyPrint = true }
|
||||||
|
val jsonString = json.encodeToString(com.atridad.openclimb.data.repository.ClimbDataExport.serializer(), exportData)
|
||||||
|
|
||||||
|
val jsonEntry = ZipEntry(DATA_JSON_FILENAME)
|
||||||
|
zipOut.putNextEntry(jsonEntry)
|
||||||
|
zipOut.write(jsonString.toByteArray())
|
||||||
|
zipOut.closeEntry()
|
||||||
|
|
||||||
|
// Add images
|
||||||
|
referencedImagePaths.forEach { imagePath ->
|
||||||
|
try {
|
||||||
|
val imageFile = ImageUtils.getImageFile(context, imagePath)
|
||||||
|
if (imageFile.exists()) {
|
||||||
|
val imageEntry = ZipEntry("$IMAGES_DIR_NAME/${imageFile.name}")
|
||||||
|
zipOut.putNextEntry(imageEntry)
|
||||||
|
|
||||||
|
FileInputStream(imageFile).use { imageInput ->
|
||||||
|
imageInput.copyTo(zipOut)
|
||||||
|
}
|
||||||
|
zipOut.closeEntry()
|
||||||
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
// Log error but continue with other images
|
||||||
|
e.printStackTrace()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return zipFile
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates a ZIP file and writes it to a provided URI
|
||||||
|
* @param context Android context
|
||||||
|
* @param uri The URI to write to
|
||||||
|
* @param exportData The data to export
|
||||||
|
* @param referencedImagePaths Set of image paths referenced in the data
|
||||||
|
*/
|
||||||
|
fun createExportZipToUri(
|
||||||
|
context: Context,
|
||||||
|
uri: android.net.Uri,
|
||||||
|
exportData: com.atridad.openclimb.data.repository.ClimbDataExport,
|
||||||
|
referencedImagePaths: Set<String>
|
||||||
|
) {
|
||||||
|
context.contentResolver.openOutputStream(uri)?.use { outputStream ->
|
||||||
|
ZipOutputStream(outputStream).use { zipOut ->
|
||||||
|
// Add JSON data file
|
||||||
|
val json = Json { prettyPrint = true }
|
||||||
|
val jsonString = json.encodeToString(com.atridad.openclimb.data.repository.ClimbDataExport.serializer(), exportData)
|
||||||
|
|
||||||
|
val jsonEntry = ZipEntry(DATA_JSON_FILENAME)
|
||||||
|
zipOut.putNextEntry(jsonEntry)
|
||||||
|
zipOut.write(jsonString.toByteArray())
|
||||||
|
zipOut.closeEntry()
|
||||||
|
|
||||||
|
// Add images
|
||||||
|
referencedImagePaths.forEach { imagePath ->
|
||||||
|
try {
|
||||||
|
val imageFile = ImageUtils.getImageFile(context, imagePath)
|
||||||
|
if (imageFile.exists()) {
|
||||||
|
val imageEntry = ZipEntry("$IMAGES_DIR_NAME/${imageFile.name}")
|
||||||
|
zipOut.putNextEntry(imageEntry)
|
||||||
|
|
||||||
|
FileInputStream(imageFile).use { imageInput ->
|
||||||
|
imageInput.copyTo(zipOut)
|
||||||
|
}
|
||||||
|
zipOut.closeEntry()
|
||||||
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
// Log error but continue with other images
|
||||||
|
e.printStackTrace()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} ?: throw IOException("Could not open output stream")
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Data class to hold extraction results
|
||||||
|
*/
|
||||||
|
data class ImportResult(
|
||||||
|
val jsonContent: String,
|
||||||
|
val importedImagePaths: Map<String, String> // original filename -> new relative path
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extracts a ZIP file and returns the JSON content and imported image paths
|
||||||
|
* @param context Android context
|
||||||
|
* @param zipFile The ZIP file to extract
|
||||||
|
* @return ImportResult containing the JSON and image path mappings
|
||||||
|
*/
|
||||||
|
fun extractImportZip(context: Context, zipFile: File): ImportResult {
|
||||||
|
var jsonContent = ""
|
||||||
|
val importedImagePaths = mutableMapOf<String, String>()
|
||||||
|
|
||||||
|
ZipInputStream(FileInputStream(zipFile)).use { zipIn ->
|
||||||
|
var entry = zipIn.nextEntry
|
||||||
|
|
||||||
|
while (entry != null) {
|
||||||
|
when {
|
||||||
|
entry.name == DATA_JSON_FILENAME -> {
|
||||||
|
// Read JSON data
|
||||||
|
jsonContent = zipIn.readBytes().toString(Charsets.UTF_8)
|
||||||
|
}
|
||||||
|
|
||||||
|
entry.name.startsWith("$IMAGES_DIR_NAME/") && !entry.isDirectory -> {
|
||||||
|
// Extract image file
|
||||||
|
val originalFilename = entry.name.substringAfter("$IMAGES_DIR_NAME/")
|
||||||
|
|
||||||
|
// Create temporary file to hold the extracted image
|
||||||
|
val tempFile = File.createTempFile("import_image_", "_$originalFilename", context.cacheDir)
|
||||||
|
|
||||||
|
FileOutputStream(tempFile).use { output ->
|
||||||
|
zipIn.copyTo(output)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Import the image to permanent storage
|
||||||
|
val newPath = ImageUtils.importImageFile(context, tempFile)
|
||||||
|
if (newPath != null) {
|
||||||
|
importedImagePaths[originalFilename] = newPath
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clean up temp file
|
||||||
|
tempFile.delete()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
zipIn.closeEntry()
|
||||||
|
entry = zipIn.nextEntry
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (jsonContent.isEmpty()) {
|
||||||
|
throw IOException("No data.json file found in the ZIP archive")
|
||||||
|
}
|
||||||
|
|
||||||
|
return ImportResult(jsonContent, importedImagePaths)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Utility function to determine if a file is a ZIP file based on extension
|
||||||
|
*/
|
||||||
|
fun isZipFile(filename: String): Boolean {
|
||||||
|
return filename.lowercase().endsWith(".zip")
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Updates image paths in a problem list after import
|
||||||
|
* This function maps the old image paths to the new ones after import
|
||||||
|
*/
|
||||||
|
fun updateProblemImagePaths(
|
||||||
|
problems: List<com.atridad.openclimb.data.model.Problem>,
|
||||||
|
imagePathMapping: Map<String, String>
|
||||||
|
): List<com.atridad.openclimb.data.model.Problem> {
|
||||||
|
return problems.map { problem ->
|
||||||
|
val updatedImagePaths = problem.imagePaths.mapNotNull { oldPath ->
|
||||||
|
// Extract filename from the old path
|
||||||
|
val filename = oldPath.substringAfterLast("/")
|
||||||
|
imagePathMapping[filename]
|
||||||
|
}
|
||||||
|
problem.copy(imagePaths = updatedImagePaths)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user