Add image compression and improve document edit UI
- Add Swift ImageCompression helper to enforce 200KB limit on iOS - Update iOS views to use compression for all image uploads - Fix iOS EditDocumentView to show success only after all uploads complete - Add AsyncImage thumbnails to Android EditDocumentScreen - Fix Android success message visibility with delay before state reset - Add proper error handling for failed image uploads - Add resetUpdateState on error for consistency 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -1,7 +1,9 @@
|
||||
package com.mycrib.android.ui.screens
|
||||
|
||||
import androidx.compose.foundation.border
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.*
|
||||
@@ -9,10 +11,13 @@ 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.layout.ContentScale
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||
import coil3.compose.AsyncImage
|
||||
import com.mycrib.android.viewmodel.DocumentViewModel
|
||||
import com.mycrib.shared.models.*
|
||||
import com.mycrib.shared.network.ApiResult
|
||||
@@ -121,13 +126,19 @@ fun EditDocumentScreen(
|
||||
is ApiResult.Success -> {
|
||||
snackbarMessage = "Document updated successfully"
|
||||
showSnackbar = true
|
||||
// Wait a bit before resetting so snackbar can be seen
|
||||
kotlinx.coroutines.delay(500)
|
||||
documentViewModel.resetUpdateState()
|
||||
// Refresh document details
|
||||
documentViewModel.loadDocumentDetail(documentId)
|
||||
// Clear new images after successful upload
|
||||
newImages = emptyList()
|
||||
imagesToDelete = emptySet()
|
||||
}
|
||||
is ApiResult.Error -> {
|
||||
snackbarMessage = (updateState as ApiResult.Error).message
|
||||
showSnackbar = true
|
||||
documentViewModel.resetUpdateState()
|
||||
}
|
||||
else -> {}
|
||||
}
|
||||
@@ -454,19 +465,40 @@ fun EditDocumentScreen(
|
||||
existingImages.forEach { image ->
|
||||
if (image.id !in imagesToDelete) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.clip(RoundedCornerShape(8.dp))
|
||||
.border(
|
||||
1.dp,
|
||||
MaterialTheme.colorScheme.outline,
|
||||
RoundedCornerShape(8.dp)
|
||||
)
|
||||
.padding(8.dp),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Row(
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier.weight(1f)
|
||||
) {
|
||||
Icon(
|
||||
// Image thumbnail
|
||||
image.imageUrl?.let { url ->
|
||||
AsyncImage(
|
||||
model = url,
|
||||
contentDescription = null,
|
||||
modifier = Modifier
|
||||
.size(60.dp)
|
||||
.clip(RoundedCornerShape(4.dp)),
|
||||
contentScale = ContentScale.Crop
|
||||
)
|
||||
} ?: Icon(
|
||||
Icons.Default.Image,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(60.dp),
|
||||
tint = MaterialTheme.colorScheme.primary
|
||||
)
|
||||
|
||||
Text(
|
||||
image.caption ?: "Image ${image.id}",
|
||||
style = MaterialTheme.typography.bodyMedium
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
package com.mycrib.util
|
||||
|
||||
import com.mycrib.platform.ImageData
|
||||
|
||||
/**
|
||||
* Platform-specific image compression
|
||||
* Compresses images to JPEG format and ensures they don't exceed MAX_IMAGE_SIZE_BYTES
|
||||
*/
|
||||
expect object ImageCompressor {
|
||||
/**
|
||||
* Compress an ImageData to JPEG format with size limit
|
||||
* @param imageData The image to compress
|
||||
* @return Compressed image data as ByteArray
|
||||
*/
|
||||
fun compressImage(imageData: ImageData): ByteArray
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
package com.mycrib.util
|
||||
|
||||
/**
|
||||
* Configuration for image uploads
|
||||
* Change MAX_IMAGE_SIZE_BYTES to adjust the maximum file size for all image uploads
|
||||
*/
|
||||
object ImageConfig {
|
||||
/**
|
||||
* Maximum image file size in bytes
|
||||
* Default: 200KB (200 * 1024 bytes)
|
||||
*/
|
||||
const val MAX_IMAGE_SIZE_BYTES = 200 * 1024 // 200KB
|
||||
|
||||
/**
|
||||
* JPEG compression quality (0-100)
|
||||
* Used as starting point, will be reduced if needed to meet size requirement
|
||||
*/
|
||||
const val INITIAL_JPEG_QUALITY = 85
|
||||
|
||||
/**
|
||||
* Minimum JPEG quality to try before giving up
|
||||
*/
|
||||
const val MIN_JPEG_QUALITY = 50
|
||||
}
|
||||
@@ -6,6 +6,7 @@ import com.mycrib.shared.models.*
|
||||
import com.mycrib.shared.network.ApiResult
|
||||
import com.mycrib.shared.network.DocumentApi
|
||||
import com.mycrib.storage.TokenStorage
|
||||
import com.mycrib.util.ImageCompressor
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.launch
|
||||
@@ -106,13 +107,23 @@ class DocumentViewModel : ViewModel() {
|
||||
_createState.value = ApiResult.Loading
|
||||
val token = TokenStorage.getToken()
|
||||
if (token != null) {
|
||||
// Convert ImageData to ByteArrays
|
||||
// Compress images and convert to ByteArrays
|
||||
val fileBytesList = if (images.isNotEmpty()) {
|
||||
images.map { it.bytes }
|
||||
images.map { ImageCompressor.compressImage(it) }
|
||||
} else null
|
||||
|
||||
val fileNamesList = if (images.isNotEmpty()) {
|
||||
images.mapIndexed { index, image -> image.fileName.ifBlank { "image_$index.jpg" } }
|
||||
images.mapIndexed { index, image ->
|
||||
// Always use .jpg extension since we compress to JPEG
|
||||
val baseName = image.fileName.ifBlank { "image_$index" }
|
||||
if (baseName.endsWith(".jpg", ignoreCase = true) ||
|
||||
baseName.endsWith(".jpeg", ignoreCase = true)) {
|
||||
baseName
|
||||
} else {
|
||||
// Remove any existing extension and add .jpg
|
||||
baseName.substringBeforeLast('.', baseName) + ".jpg"
|
||||
}
|
||||
}
|
||||
} else null
|
||||
|
||||
val mimeTypesList = if (images.isNotEmpty()) {
|
||||
@@ -183,9 +194,8 @@ class DocumentViewModel : ViewModel() {
|
||||
_updateState.value = ApiResult.Loading
|
||||
val token = TokenStorage.getToken()
|
||||
if (token != null) {
|
||||
// For now, we'll just update metadata without file support
|
||||
// File/image updates can be added later if needed
|
||||
_updateState.value = documentApi.updateDocument(
|
||||
// First, update the document metadata
|
||||
val updateResult = documentApi.updateDocument(
|
||||
token = token,
|
||||
id = id,
|
||||
title = title,
|
||||
@@ -208,6 +218,52 @@ class DocumentViewModel : ViewModel() {
|
||||
startDate = startDate,
|
||||
endDate = endDate
|
||||
)
|
||||
|
||||
// If update succeeded and there are new images, upload them
|
||||
if (updateResult is ApiResult.Success && images.isNotEmpty()) {
|
||||
var uploadFailed = false
|
||||
for ((index, image) in images.withIndex()) {
|
||||
// Compress the image
|
||||
val compressedBytes = ImageCompressor.compressImage(image)
|
||||
|
||||
// Determine filename with .jpg extension
|
||||
val fileName = if (image.fileName.isNotBlank()) {
|
||||
val baseName = image.fileName
|
||||
if (baseName.endsWith(".jpg", ignoreCase = true) ||
|
||||
baseName.endsWith(".jpeg", ignoreCase = true)) {
|
||||
baseName
|
||||
} else {
|
||||
baseName.substringBeforeLast('.', baseName) + ".jpg"
|
||||
}
|
||||
} else {
|
||||
"image_$index.jpg"
|
||||
}
|
||||
|
||||
val uploadResult = documentApi.uploadDocumentImage(
|
||||
token = token,
|
||||
documentId = id,
|
||||
imageBytes = compressedBytes,
|
||||
fileName = fileName,
|
||||
mimeType = "image/jpeg"
|
||||
)
|
||||
|
||||
if (uploadResult is ApiResult.Error) {
|
||||
uploadFailed = true
|
||||
_updateState.value = ApiResult.Error(
|
||||
"Document updated but failed to upload image: ${uploadResult.message}",
|
||||
uploadResult.code
|
||||
)
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
// If all uploads succeeded, set success state
|
||||
if (!uploadFailed) {
|
||||
_updateState.value = updateResult
|
||||
}
|
||||
} else {
|
||||
_updateState.value = updateResult
|
||||
}
|
||||
} else {
|
||||
_updateState.value = ApiResult.Error("Not authenticated", 401)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user