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

@@ -0,0 +1,53 @@
package com.mycrib.util
import android.graphics.Bitmap
import android.graphics.BitmapFactory
import com.mycrib.platform.ImageData
import java.io.ByteArrayOutputStream
/**
* Android implementation of image compression
* Compresses images to JPEG format and ensures they don't exceed MAX_IMAGE_SIZE_BYTES
*/
actual object ImageCompressor {
/**
* Compress an ImageData to JPEG format with size limit
* @param imageData The image to compress
* @return Compressed image data as ByteArray
*/
actual fun compressImage(imageData: ImageData): ByteArray {
// Decode the original image
val originalBitmap = BitmapFactory.decodeByteArray(
imageData.bytes,
0,
imageData.bytes.size
)
// Compress with iterative quality reduction
return compressBitmapToTarget(originalBitmap, ImageConfig.MAX_IMAGE_SIZE_BYTES)
}
/**
* Compress a bitmap to target size
*/
private fun compressBitmapToTarget(bitmap: Bitmap, targetSizeBytes: Int): ByteArray {
var quality = ImageConfig.INITIAL_JPEG_QUALITY
var compressedData: ByteArray
do {
val outputStream = ByteArrayOutputStream()
bitmap.compress(Bitmap.CompressFormat.JPEG, quality, outputStream)
compressedData = outputStream.toByteArray()
// If size is acceptable or quality is too low, stop
if (compressedData.size <= targetSizeBytes || quality <= ImageConfig.MIN_JPEG_QUALITY) {
break
}
// Reduce quality for next iteration
quality -= 5
} while (true)
return compressedData
}
}

View File

@@ -1,7 +1,9 @@
package com.mycrib.android.ui.screens package com.mycrib.android.ui.screens
import androidx.compose.foundation.border
import androidx.compose.foundation.layout.* import androidx.compose.foundation.layout.*
import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.* import androidx.compose.material.icons.filled.*
@@ -9,10 +11,13 @@ import androidx.compose.material3.*
import androidx.compose.runtime.* import androidx.compose.runtime.*
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.lifecycle.viewmodel.compose.viewModel import androidx.lifecycle.viewmodel.compose.viewModel
import coil3.compose.AsyncImage
import com.mycrib.android.viewmodel.DocumentViewModel import com.mycrib.android.viewmodel.DocumentViewModel
import com.mycrib.shared.models.* import com.mycrib.shared.models.*
import com.mycrib.shared.network.ApiResult import com.mycrib.shared.network.ApiResult
@@ -121,13 +126,19 @@ fun EditDocumentScreen(
is ApiResult.Success -> { is ApiResult.Success -> {
snackbarMessage = "Document updated successfully" snackbarMessage = "Document updated successfully"
showSnackbar = true showSnackbar = true
// Wait a bit before resetting so snackbar can be seen
kotlinx.coroutines.delay(500)
documentViewModel.resetUpdateState() documentViewModel.resetUpdateState()
// Refresh document details // Refresh document details
documentViewModel.loadDocumentDetail(documentId) documentViewModel.loadDocumentDetail(documentId)
// Clear new images after successful upload
newImages = emptyList()
imagesToDelete = emptySet()
} }
is ApiResult.Error -> { is ApiResult.Error -> {
snackbarMessage = (updateState as ApiResult.Error).message snackbarMessage = (updateState as ApiResult.Error).message
showSnackbar = true showSnackbar = true
documentViewModel.resetUpdateState()
} }
else -> {} else -> {}
} }
@@ -454,19 +465,40 @@ fun EditDocumentScreen(
existingImages.forEach { image -> existingImages.forEach { image ->
if (image.id !in imagesToDelete) { if (image.id !in imagesToDelete) {
Row( 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, horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically verticalAlignment = Alignment.CenterVertically
) { ) {
Row( Row(
horizontalArrangement = Arrangement.spacedBy(8.dp), horizontalArrangement = Arrangement.spacedBy(12.dp),
verticalAlignment = Alignment.CenterVertically 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, Icons.Default.Image,
contentDescription = null, contentDescription = null,
modifier = Modifier.size(60.dp),
tint = MaterialTheme.colorScheme.primary tint = MaterialTheme.colorScheme.primary
) )
Text( Text(
image.caption ?: "Image ${image.id}", image.caption ?: "Image ${image.id}",
style = MaterialTheme.typography.bodyMedium 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.ApiResult
import com.mycrib.shared.network.DocumentApi import com.mycrib.shared.network.DocumentApi
import com.mycrib.storage.TokenStorage import com.mycrib.storage.TokenStorage
import com.mycrib.util.ImageCompressor
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
@@ -106,13 +107,23 @@ class DocumentViewModel : ViewModel() {
_createState.value = ApiResult.Loading _createState.value = ApiResult.Loading
val token = TokenStorage.getToken() val token = TokenStorage.getToken()
if (token != null) { if (token != null) {
// Convert ImageData to ByteArrays // Compress images and convert to ByteArrays
val fileBytesList = if (images.isNotEmpty()) { val fileBytesList = if (images.isNotEmpty()) {
images.map { it.bytes } images.map { ImageCompressor.compressImage(it) }
} else null } else null
val fileNamesList = if (images.isNotEmpty()) { 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 } else null
val mimeTypesList = if (images.isNotEmpty()) { val mimeTypesList = if (images.isNotEmpty()) {
@@ -183,9 +194,8 @@ class DocumentViewModel : ViewModel() {
_updateState.value = ApiResult.Loading _updateState.value = ApiResult.Loading
val token = TokenStorage.getToken() val token = TokenStorage.getToken()
if (token != null) { if (token != null) {
// For now, we'll just update metadata without file support // First, update the document metadata
// File/image updates can be added later if needed val updateResult = documentApi.updateDocument(
_updateState.value = documentApi.updateDocument(
token = token, token = token,
id = id, id = id,
title = title, title = title,
@@ -208,6 +218,52 @@ class DocumentViewModel : ViewModel() {
startDate = startDate, startDate = startDate,
endDate = endDate 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 { } else {
_updateState.value = ApiResult.Error("Not authenticated", 401) _updateState.value = ApiResult.Error("Not authenticated", 401)
} }

View File

@@ -0,0 +1,69 @@
package com.mycrib.util
import com.mycrib.platform.ImageData
import kotlinx.cinterop.*
import platform.Foundation.*
import platform.UIKit.*
import platform.posix.memcpy
/**
* iOS implementation of image compression
* Compresses images to JPEG format and ensures they don't exceed MAX_IMAGE_SIZE_BYTES
*/
@OptIn(ExperimentalForeignApi::class)
actual object ImageCompressor {
/**
* Compress an ImageData to JPEG format with size limit
* @param imageData The image to compress
* @return Compressed image data as ByteArray
*/
actual fun compressImage(imageData: ImageData): ByteArray {
// Convert ByteArray to NSData
val nsData = imageData.bytes.usePinned { pinned ->
NSData.create(
bytes = pinned.addressOf(0),
length = imageData.bytes.size.toULong()
)
}
// Create UIImage from data
val image = UIImage.imageWithData(nsData) ?: return imageData.bytes
// Compress with iterative quality reduction
return compressImageToTarget(image, ImageConfig.MAX_IMAGE_SIZE_BYTES.toLong())
}
/**
* Compress a UIImage to target size
*/
private fun compressImageToTarget(image: UIImage, targetSizeBytes: Long): ByteArray {
var quality = ImageConfig.INITIAL_JPEG_QUALITY / 100.0
var compressedData: NSData? = null
while (quality >= ImageConfig.MIN_JPEG_QUALITY / 100.0) {
compressedData = UIImageJPEGRepresentation(image, quality)
if (compressedData != null && compressedData.length.toLong() <= targetSizeBytes) {
break
}
quality -= 0.05 // Reduce quality by 5%
}
// Convert NSData to ByteArray
return compressedData?.toByteArray() ?: image.let {
UIImageJPEGRepresentation(it, ImageConfig.MIN_JPEG_QUALITY / 100.0)?.toByteArray() ?: ByteArray(0)
}
}
/**
* Convert NSData to ByteArray
*/
private fun NSData.toByteArray(): ByteArray {
return ByteArray(this.length.toInt()).apply {
usePinned {
memcpy(it.addressOf(0), this@toByteArray.bytes, this@toByteArray.length)
}
}
}
}

View File

@@ -375,7 +375,8 @@ struct AddDocumentView: View {
var typesList: [String] = [] var typesList: [String] = []
for (index, image) in selectedImages.enumerated() { for (index, image) in selectedImages.enumerated() {
if let imageData = image.jpegData(compressionQuality: 0.8) { // Compress image to meet size requirements
if let imageData = ImageCompression.compressImage(image) {
bytesList.append(KotlinByteArray(data: imageData)) bytesList.append(KotlinByteArray(data: imageData))
namesList.append("image_\(index).jpg") namesList.append("image_\(index).jpg")
typesList.append("image/jpeg") typesList.append("image/jpeg")

View File

@@ -413,7 +413,8 @@ struct EditDocumentView: View {
let documentApi = DocumentApi(client: ApiClient_iosKt.createHttpClient()) let documentApi = DocumentApi(client: ApiClient_iosKt.createHttpClient())
for (index, image) in newImages.enumerated() { for (index, image) in newImages.enumerated() {
if let imageData = image.jpegData(compressionQuality: 0.8) { // Compress image to meet size requirements
if let imageData = ImageCompression.compressImage(image) {
let result = try await documentApi.uploadDocumentImage( let result = try await documentApi.uploadDocumentImage(
token: token, token: token,
documentId: documentId.int32Value, documentId: documentId.int32Value,
@@ -424,11 +425,20 @@ struct EditDocumentView: View {
) )
if result is ApiResultError { if result is ApiResultError {
print("Failed to upload image \(index)") let error = result as! ApiResultError
throw NSError(domain: "DocumentUpload", code: -1,
userInfo: [NSLocalizedDescriptionKey: "Failed to upload image \(index): \(error.message)"])
} }
} }
} }
} }
// All operations completed successfully
await MainActor.run {
alertMessage = "Document updated successfully"
showAlert = true
presentationMode.wrappedValue.dismiss()
}
} catch { } catch {
await MainActor.run { await MainActor.run {
alertMessage = "Error saving document: \(error.localizedDescription)" alertMessage = "Error saving document: \(error.localizedDescription)"

View File

@@ -0,0 +1,45 @@
import UIKit
import ComposeApp
/// Helper class for compressing images to meet size requirements
class ImageCompression {
/// Maximum image size in bytes (200KB)
/// Change this value to adjust the size limit for all image uploads
static let maxImageSizeBytes = 200 * 1024 // 200KB
/// Initial JPEG compression quality
private static let initialQuality: CGFloat = 0.85
/// Minimum JPEG compression quality
private static let minQuality: CGFloat = 0.20
/// Compress an image to JPEG format with size limit
/// - Parameter image: The UIImage to compress
/// - Returns: Compressed image data as Data, or nil if compression fails
static func compressImage(_ image: UIImage) -> Data? {
var quality = initialQuality
var compressedData: Data?
// Iteratively reduce quality until we meet the size requirement
while quality >= minQuality {
compressedData = image.jpegData(compressionQuality: quality)
if let data = compressedData, data.count <= maxImageSizeBytes {
break
}
// Reduce quality by 5%
quality -= 0.05
}
// If we still don't have data or it's still too large, return the last attempt
return compressedData ?? image.jpegData(compressionQuality: minQuality)
}
/// Compress multiple images
/// - Parameter images: Array of UIImages to compress
/// - Returns: Array of compressed image data
static func compressImages(_ images: [UIImage]) -> [Data] {
return images.compactMap { compressImage($0) }
}
}

View File

@@ -314,7 +314,8 @@ struct CompleteTaskView: View {
// If there are images, upload with images // If there are images, upload with images
if !selectedImages.isEmpty { if !selectedImages.isEmpty {
let imageDataArray = selectedImages.compactMap { $0.jpegData(compressionQuality: 0.8) } // Compress images to meet size requirements
let imageDataArray = ImageCompression.compressImages(selectedImages)
let imageByteArrays = imageDataArray.map { KotlinByteArray(data: $0) } let imageByteArrays = imageDataArray.map { KotlinByteArray(data: $0) }
let fileNames = (0..<imageDataArray.count).map { "image_\($0).jpg" } let fileNames = (0..<imageDataArray.count).map { "image_\($0).jpg" }