Added photos and ZIP exports

This commit is contained in:
2025-08-15 14:22:17 -06:00
parent 4617f02e4f
commit 92f43daec1
5 changed files with 872 additions and 0 deletions

View File

@@ -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
)
}
}

View File

@@ -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
)
}
}
}

View File

@@ -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
)
}
}
}
}

View 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()
}
}
}

View File

@@ -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)
}
}
}