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:
Trey t
2025-11-11 21:23:22 -06:00
parent 7740438ea6
commit 22949d18a7
10 changed files with 321 additions and 14 deletions

View File

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

View File

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

View File

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

View File

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