diff --git a/composeApp/src/androidMain/kotlin/com/example/mycrib/util/ImageCompressor.android.kt b/composeApp/src/androidMain/kotlin/com/example/mycrib/util/ImageCompressor.android.kt new file mode 100644 index 0000000..caf1ba0 --- /dev/null +++ b/composeApp/src/androidMain/kotlin/com/example/mycrib/util/ImageCompressor.android.kt @@ -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 + } +} diff --git a/composeApp/src/commonMain/kotlin/com/example/mycrib/ui/screens/EditDocumentScreen.kt b/composeApp/src/commonMain/kotlin/com/example/mycrib/ui/screens/EditDocumentScreen.kt index 994ecf1..a33d306 100644 --- a/composeApp/src/commonMain/kotlin/com/example/mycrib/ui/screens/EditDocumentScreen.kt +++ b/composeApp/src/commonMain/kotlin/com/example/mycrib/ui/screens/EditDocumentScreen.kt @@ -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 diff --git a/composeApp/src/commonMain/kotlin/com/example/mycrib/util/ImageCompressor.kt b/composeApp/src/commonMain/kotlin/com/example/mycrib/util/ImageCompressor.kt new file mode 100644 index 0000000..6a2365c --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/example/mycrib/util/ImageCompressor.kt @@ -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 +} diff --git a/composeApp/src/commonMain/kotlin/com/example/mycrib/util/ImageConfig.kt b/composeApp/src/commonMain/kotlin/com/example/mycrib/util/ImageConfig.kt new file mode 100644 index 0000000..61603ab --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/example/mycrib/util/ImageConfig.kt @@ -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 +} diff --git a/composeApp/src/commonMain/kotlin/com/example/mycrib/viewmodel/DocumentViewModel.kt b/composeApp/src/commonMain/kotlin/com/example/mycrib/viewmodel/DocumentViewModel.kt index b1420c0..7472efc 100644 --- a/composeApp/src/commonMain/kotlin/com/example/mycrib/viewmodel/DocumentViewModel.kt +++ b/composeApp/src/commonMain/kotlin/com/example/mycrib/viewmodel/DocumentViewModel.kt @@ -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) } diff --git a/composeApp/src/iosMain/kotlin/com/example/mycrib/util/ImageCompressor.ios.kt b/composeApp/src/iosMain/kotlin/com/example/mycrib/util/ImageCompressor.ios.kt new file mode 100644 index 0000000..a44dfc6 --- /dev/null +++ b/composeApp/src/iosMain/kotlin/com/example/mycrib/util/ImageCompressor.ios.kt @@ -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) + } + } + } +} diff --git a/iosApp/iosApp/Documents/AddDocumentView.swift b/iosApp/iosApp/Documents/AddDocumentView.swift index 4bffbfe..da85a48 100644 --- a/iosApp/iosApp/Documents/AddDocumentView.swift +++ b/iosApp/iosApp/Documents/AddDocumentView.swift @@ -375,7 +375,8 @@ struct AddDocumentView: View { var typesList: [String] = [] 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)) namesList.append("image_\(index).jpg") typesList.append("image/jpeg") diff --git a/iosApp/iosApp/Documents/EditDocumentView.swift b/iosApp/iosApp/Documents/EditDocumentView.swift index ebae641..2754cb1 100644 --- a/iosApp/iosApp/Documents/EditDocumentView.swift +++ b/iosApp/iosApp/Documents/EditDocumentView.swift @@ -413,7 +413,8 @@ struct EditDocumentView: View { let documentApi = DocumentApi(client: ApiClient_iosKt.createHttpClient()) 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( token: token, documentId: documentId.int32Value, @@ -424,11 +425,20 @@ struct EditDocumentView: View { ) 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 { await MainActor.run { alertMessage = "Error saving document: \(error.localizedDescription)" diff --git a/iosApp/iosApp/Helpers/ImageCompression.swift b/iosApp/iosApp/Helpers/ImageCompression.swift new file mode 100644 index 0000000..41267db --- /dev/null +++ b/iosApp/iosApp/Helpers/ImageCompression.swift @@ -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) } + } +} diff --git a/iosApp/iosApp/Task/CompleteTaskView.swift b/iosApp/iosApp/Task/CompleteTaskView.swift index ab83da6..5bd1c9d 100644 --- a/iosApp/iosApp/Task/CompleteTaskView.swift +++ b/iosApp/iosApp/Task/CompleteTaskView.swift @@ -314,7 +314,8 @@ struct CompleteTaskView: View { // If there are images, upload with images 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 fileNames = (0..