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