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:
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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.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)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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")
|
||||||
|
|||||||
@@ -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)"
|
||||||
|
|||||||
45
iosApp/iosApp/Helpers/ImageCompression.swift
Normal file
45
iosApp/iosApp/Helpers/ImageCompression.swift
Normal 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) }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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" }
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user