From c061ed830a0489721410457c6c85c6aa60d4f11f Mon Sep 17 00:00:00 2001 From: Atridad Lahiji Date: Fri, 15 Aug 2025 14:22:17 -0600 Subject: [PATCH] Added photos and ZIP exports --- .../ui/components/FullscreenImageViewer.kt | 209 ++++++++++++++++++ .../openclimb/ui/components/ImageDisplay.kt | 76 +++++++ .../openclimb/ui/components/ImagePicker.kt | 184 +++++++++++++++ .../com/atridad/openclimb/utils/ImageUtils.kt | 196 ++++++++++++++++ .../openclimb/utils/ZipExportImportUtils.kt | 207 +++++++++++++++++ 5 files changed, 872 insertions(+) create mode 100644 app/src/main/java/com/atridad/openclimb/ui/components/FullscreenImageViewer.kt create mode 100644 app/src/main/java/com/atridad/openclimb/ui/components/ImageDisplay.kt create mode 100644 app/src/main/java/com/atridad/openclimb/ui/components/ImagePicker.kt create mode 100644 app/src/main/java/com/atridad/openclimb/utils/ImageUtils.kt create mode 100644 app/src/main/java/com/atridad/openclimb/utils/ZipExportImportUtils.kt diff --git a/app/src/main/java/com/atridad/openclimb/ui/components/FullscreenImageViewer.kt b/app/src/main/java/com/atridad/openclimb/ui/components/FullscreenImageViewer.kt new file mode 100644 index 0000000..c832536 --- /dev/null +++ b/app/src/main/java/com/atridad/openclimb/ui/components/FullscreenImageViewer.kt @@ -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, + 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 + ) + } +} diff --git a/app/src/main/java/com/atridad/openclimb/ui/components/ImageDisplay.kt b/app/src/main/java/com/atridad/openclimb/ui/components/ImageDisplay.kt new file mode 100644 index 0000000..246b913 --- /dev/null +++ b/app/src/main/java/com/atridad/openclimb/ui/components/ImageDisplay.kt @@ -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, + 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, + 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 + ) + } + } +} diff --git a/app/src/main/java/com/atridad/openclimb/ui/components/ImagePicker.kt b/app/src/main/java/com/atridad/openclimb/ui/components/ImagePicker.kt new file mode 100644 index 0000000..61b7152 --- /dev/null +++ b/app/src/main/java/com/atridad/openclimb/ui/components/ImagePicker.kt @@ -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, + onImagesChanged: (List) -> 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() + 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 + ) + } + } + } +} diff --git a/app/src/main/java/com/atridad/openclimb/utils/ImageUtils.kt b/app/src/main/java/com/atridad/openclimb/utils/ImageUtils.kt new file mode 100644 index 0000000..448cf57 --- /dev/null +++ b/app/src/main/java/com/atridad/openclimb/utils/ImageUtils.kt @@ -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 { + 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) { + try { + val allImages = getAllImages(context) + val orphanedImages = allImages.filter { it !in referencedPaths } + + orphanedImages.forEach { path -> + deleteImage(context, path) + } + } catch (e: Exception) { + e.printStackTrace() + } + } +} diff --git a/app/src/main/java/com/atridad/openclimb/utils/ZipExportImportUtils.kt b/app/src/main/java/com/atridad/openclimb/utils/ZipExportImportUtils.kt new file mode 100644 index 0000000..0438e44 --- /dev/null +++ b/app/src/main/java/com/atridad/openclimb/utils/ZipExportImportUtils.kt @@ -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, + 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 + ) { + 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 // 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() + + 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, + imagePathMapping: Map + ): List { + 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) + } + } +}