From 2aeae75eea0e3d72abb78b854ecf282834b8f254 Mon Sep 17 00:00:00 2001 From: BohunD Date: Tue, 15 Jul 2025 11:57:44 +0300 Subject: [PATCH 1/5] added imagePicker --- common/build.gradle | 1 + design/build.gradle | 6 + .../design/ui/imagePicker/ImagePicker.kt | 223 ++++++++++++++++++ .../ui/imagePicker/ImagePickerConstants.kt | 5 + .../ui/imagePicker/ImagePickerDelegate.kt | 15 ++ .../ui/imagePicker/ImagePickerDelegateImpl.kt | 25 ++ .../ui/imagePicker/ImagePickerDialogConfig.kt | 24 ++ .../imagePicker/ImagePickerSelectorDialog.kt | 128 ++++++++++ .../design/ui/imagePicker/PhotoSize.kt | 19 ++ gradle/libs.versions.toml | 1 + 10 files changed, 447 insertions(+) create mode 100644 design/src/main/java/com/urlaunched/android/design/ui/imagePicker/ImagePicker.kt create mode 100644 design/src/main/java/com/urlaunched/android/design/ui/imagePicker/ImagePickerConstants.kt create mode 100644 design/src/main/java/com/urlaunched/android/design/ui/imagePicker/ImagePickerDelegate.kt create mode 100644 design/src/main/java/com/urlaunched/android/design/ui/imagePicker/ImagePickerDelegateImpl.kt create mode 100644 design/src/main/java/com/urlaunched/android/design/ui/imagePicker/ImagePickerDialogConfig.kt create mode 100644 design/src/main/java/com/urlaunched/android/design/ui/imagePicker/ImagePickerSelectorDialog.kt create mode 100644 design/src/main/java/com/urlaunched/android/design/ui/imagePicker/PhotoSize.kt diff --git a/common/build.gradle b/common/build.gradle index 4d1b8835..bc4966b8 100644 --- a/common/build.gradle +++ b/common/build.gradle @@ -106,6 +106,7 @@ dependencies { // Accompanist implementation libs.composeAccompanist.navigation + implementation libs.composeAccompanist.permissions // Kotlin reflect implementation libs.kotlin.reflect diff --git a/design/build.gradle b/design/build.gradle index 38319e3a..c304d73a 100644 --- a/design/build.gradle +++ b/design/build.gradle @@ -72,6 +72,7 @@ dependencies { implementation libs.composeDependencies.composeNavigation implementation libs.composeDependencies.composeConstraintLayout implementation libs.composeDependencies.lifecycleRuntime + implementation libs.composeDependencies.runtime debugImplementation libs.composeDependencies.composeUiTooling debugImplementation libs.composeDependencies.composeUiTestManifest @@ -91,7 +92,12 @@ dependencies { // Local modules implementation project(":cdn:models:presentation") + implementation project(":common") + implementation project(":tempattachment") // Bottom sheet implementation libs.bottomSheet + + implementation libs.composeAccompanist.permissions + } \ No newline at end of file diff --git a/design/src/main/java/com/urlaunched/android/design/ui/imagePicker/ImagePicker.kt b/design/src/main/java/com/urlaunched/android/design/ui/imagePicker/ImagePicker.kt new file mode 100644 index 00000000..2c1951e1 --- /dev/null +++ b/design/src/main/java/com/urlaunched/android/design/ui/imagePicker/ImagePicker.kt @@ -0,0 +1,223 @@ +package com.urlaunched.android.design.ui.imagePicker + +import android.Manifest +import android.content.Context +import android.net.Uri +import androidx.activity.compose.ManagedActivityResultLauncher +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.PickVisualMediaRequest +import androidx.activity.result.contract.ActivityResultContracts +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalInspectionMode +import com.google.accompanist.permissions.ExperimentalPermissionsApi +import com.google.accompanist.permissions.isGranted +import com.google.accompanist.permissions.rememberPermissionState +import com.urlaunched.android.common.compression.CompressImageUtil +import com.urlaunched.android.common.files.FileHelper +import com.urlaunched.android.common.files.FilePickerHelper +import com.urlaunched.android.common.files.TakeCameraPictureContract +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import java.io.File + +private const val FILE_PROVIDER = ".provider" + +@OptIn(ExperimentalPermissionsApi::class) +@Suppress("ktlint:ktlintrules:composable-modifier-missing-rule") +@Composable +fun ImagePicker( + dialogModifier: Modifier = Modifier, + dialogConfig: ImagePickerDialogConfig, + onFilesChanges: ((file: List) -> Unit)?, + deleteTempFilesWhenOnDispose: Boolean = true, + maxFiles: Int = ImagePickerConstants.MAX_PHOTOS_AMOUNT, + needToPickMultipleFiles: Boolean = false, + showSnackbar: suspend (message: String) -> Unit, + setIsCompressionProceed: (isCompressionProceed: Boolean) -> Unit = {}, + content: @Composable (onClick: () -> Unit) -> Unit +) { + if (!LocalInspectionMode.current) { + val context = LocalContext.current + val coroutineScope = rememberCoroutineScope() + var showSelectorDialog by remember { mutableStateOf(false) } + + val cameraPickerContract = + remember { TakeCameraPictureContract(fileProviderAuthority = context.packageName + FILE_PROVIDER) } + + val cameraLauncher = rememberLauncherForActivityResult( + contract = cameraPickerContract, + onResult = { file -> + if (file != null) { + validateFilesAndCompress( + files = listOf(file), + context = context, + coroutineScope = coroutineScope, + onFileChanges = onFilesChanges, + showSnackbar = showSnackbar, + setIsCompressionProceed = setIsCompressionProceed + ) + } + } + ) + + val pickerLauncher = rememberLauncherForActivityResult( + contract = ActivityResultContracts.PickVisualMedia(), + onResult = { uri -> + uri?.let { + FilePickerHelper.createFileFromUri(context, it)?.let { file -> + validateFilesAndCompress( + files = listOf(file), + context = context, + coroutineScope = coroutineScope, + onFileChanges = onFilesChanges, + showSnackbar = showSnackbar, + setIsCompressionProceed = setIsCompressionProceed + ) + } + } + } + ) + + val multiplePhotoPickerLauncher = if (maxFiles > 1) { + rememberLauncherForActivityResult( + contract = ActivityResultContracts.PickMultipleVisualMedia(maxItems = maxFiles), + onResult = { uris -> + val files = mutableListOf() + uris.forEach { + FilePickerHelper.createFileFromUri(context, it)?.let { file -> + files.add(file) + } + } + validateFilesAndCompress( + files = files, + context = context, + coroutineScope = coroutineScope, + onFileChanges = onFilesChanges, + showSnackbar = showSnackbar, + setIsCompressionProceed = setIsCompressionProceed + ) + } + ) + } else { + null + } + + val cameraPermissionState = rememberPermissionState(permission = Manifest.permission.CAMERA) + + val cameraRequestPermissionLauncher = rememberLauncherForActivityResult( + ActivityResultContracts.RequestPermission() + ) { granted -> + if (granted) { + launchCameraPicker(cameraLauncher) + } + } + + if (deleteTempFilesWhenOnDispose) { + DisposableEffect(Unit) { + onDispose { + FileHelper.deleteTempFilesFromCache(context) + } + } + } + + content { + showSelectorDialog = true + } + + if (showSelectorDialog) { + ImagePickerSelectorDialog( + + onGallerySelectClick = { + if (needToPickMultipleFiles && maxFiles > 1 && multiplePhotoPickerLauncher != null) { + launchMultipleImagePicker(multiplePhotoPickerLauncher) + } else { + launchImagePicker(pickerLauncher) + } + }, + onCameraSelectClick = { + if (!cameraPermissionState.status.isGranted) { + cameraRequestPermissionLauncher.launch(Manifest.permission.CAMERA) + } else { + launchCameraPicker(cameraLauncher) + } + }, + onDismiss = { showSelectorDialog = false }, + modifier = dialogModifier, + config = dialogConfig + ) + } + } else { + content {} + } +} + +private fun validateFilesAndCompress( + files: List, + onFileChanges: ((file: List) -> Unit)?, + context: Context, + coroutineScope: CoroutineScope, + showSnackbar: suspend (message: String) -> Unit, + setIsCompressionProceed: (isUnderCompression: Boolean) -> Unit +) { + val maxImageSize = 15 * 1024 * 1024 + val targetImageSize = 5 * 1000 * 1000 + + val validFiles = mutableListOf() + val compressedFiles = mutableListOf() + + coroutineScope.launch(Dispatchers.IO) { + setIsCompressionProceed(true) + + files.forEach { file -> + if (file.length() > maxImageSize) { + showSnackbar("Photo must be less 15 mb") + } else if (file.length() < targetImageSize) { + validFiles.add(file) + } else { + try { + val compressedFile = CompressImageUtil.compressImage(file, context) + compressedFile?.let { compressedFiles.add(it) } + ?: showSnackbar("Something went wrong") + } catch (e: Exception) { + showSnackbar("Something went wrong") + return@launch + } + } + } + + validFiles.addAll(compressedFiles) + onFileChanges?.invoke(validFiles) + setIsCompressionProceed(false) + } +} + +private fun launchImagePicker(pickerLauncher: ManagedActivityResultLauncher) { + pickerLauncher.launch( + PickVisualMediaRequest( + mediaType = ActivityResultContracts.PickVisualMedia.ImageOnly + ) + ) +} + +private fun launchCameraPicker(pickerLauncher: ManagedActivityResultLauncher) { + pickerLauncher.launch(Unit) +} + +private fun launchMultipleImagePicker( + pickerLauncher: ManagedActivityResultLauncher> +) { + pickerLauncher.launch( + PickVisualMediaRequest( + mediaType = ActivityResultContracts.PickVisualMedia.ImageOnly + ) + ) +} \ No newline at end of file diff --git a/design/src/main/java/com/urlaunched/android/design/ui/imagePicker/ImagePickerConstants.kt b/design/src/main/java/com/urlaunched/android/design/ui/imagePicker/ImagePickerConstants.kt new file mode 100644 index 00000000..20ea088c --- /dev/null +++ b/design/src/main/java/com/urlaunched/android/design/ui/imagePicker/ImagePickerConstants.kt @@ -0,0 +1,5 @@ +package com.urlaunched.android.design.ui.imagePicker + +internal object ImagePickerConstants { + const val MAX_PHOTOS_AMOUNT = 6 +} \ No newline at end of file diff --git a/design/src/main/java/com/urlaunched/android/design/ui/imagePicker/ImagePickerDelegate.kt b/design/src/main/java/com/urlaunched/android/design/ui/imagePicker/ImagePickerDelegate.kt new file mode 100644 index 00000000..16d397b7 --- /dev/null +++ b/design/src/main/java/com/urlaunched/android/design/ui/imagePicker/ImagePickerDelegate.kt @@ -0,0 +1,15 @@ +package com.urlaunched.android.design.ui.imagePicker + +import androidx.compose.ui.text.input.TextFieldValue +import kotlinx.coroutines.flow.StateFlow +import java.io.File + +interface ImagePickerDelegate { + val uiImagePickerStage: StateFlow + + fun selectFile(file: File?) +} + +data class ImagePickerDelegateState( + val selectedFile: File?, +) \ No newline at end of file diff --git a/design/src/main/java/com/urlaunched/android/design/ui/imagePicker/ImagePickerDelegateImpl.kt b/design/src/main/java/com/urlaunched/android/design/ui/imagePicker/ImagePickerDelegateImpl.kt new file mode 100644 index 00000000..604874f9 --- /dev/null +++ b/design/src/main/java/com/urlaunched/android/design/ui/imagePicker/ImagePickerDelegateImpl.kt @@ -0,0 +1,25 @@ +package com.urlaunched.android.design.ui.imagePicker + +import androidx.compose.ui.text.TextRange +import androidx.compose.ui.text.input.TextFieldValue +import com.urlaunched.android.design.delegate.Delegate +import com.urlaunched.android.design.ui.textfield.hashtags.HashtagsDelegate +import com.urlaunched.android.design.ui.textfield.hashtags.HashtagsDelegateState +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.update +import java.io.File + +class ImagePickerDelegateImpl( + private val coroutineDispatcher: CoroutineDispatcher +) : Delegate(coroutineDispatcher), ImagePickerDelegate { + private val _uiState = MutableStateFlow(ImagePickerDelegateState(selectedFile = null)) + + override val uiImagePickerStage: StateFlow = _uiState + override fun selectFile(file: File?) { + _uiState.update { uiState -> + uiState.copy(selectedFile = file) + } + } +} \ No newline at end of file diff --git a/design/src/main/java/com/urlaunched/android/design/ui/imagePicker/ImagePickerDialogConfig.kt b/design/src/main/java/com/urlaunched/android/design/ui/imagePicker/ImagePickerDialogConfig.kt new file mode 100644 index 00000000..24131f6d --- /dev/null +++ b/design/src/main/java/com/urlaunched/android/design/ui/imagePicker/ImagePickerDialogConfig.kt @@ -0,0 +1,24 @@ +package com.urlaunched.android.design.ui.imagePicker + +import androidx.compose.runtime.Composable +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.TextStyle + +data class ImagePickerDialogConfig( + val headerText: String, + val cameraText: String, + val galleryText: String, + val onDismissText: String = "Cancel", + + val headerTextStyle: TextStyle, + val labelTextStyle: TextStyle, + val cancelTextStyle: TextStyle, + + val iconCamera: @Composable () -> Unit, + val iconGallery: @Composable () -> Unit, + + val containerColor: Color, + val headerTextColor: Color, + val labelTextColor: Color, + val cancelTextColor: Color +) diff --git a/design/src/main/java/com/urlaunched/android/design/ui/imagePicker/ImagePickerSelectorDialog.kt b/design/src/main/java/com/urlaunched/android/design/ui/imagePicker/ImagePickerSelectorDialog.kt new file mode 100644 index 00000000..c856c87d --- /dev/null +++ b/design/src/main/java/com/urlaunched/android/design/ui/imagePicker/ImagePickerSelectorDialog.kt @@ -0,0 +1,128 @@ +package com.urlaunched.android.design.ui.imagePicker + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.window.Dialog +import com.urlaunched.android.design.resources.dimens.Dimens + +@Composable +fun ImagePickerSelectorDialog( + modifier: Modifier = Modifier, + config: ImagePickerDialogConfig, + onGallerySelectClick: () -> Unit, + onCameraSelectClick: () -> Unit, + onDismiss: () -> Unit +) { + Dialog(onDismissRequest = { onDismiss() }) { + Card( + modifier = modifier, + shape = RoundedCornerShape(Dimens.cornerRadiusNormal), + colors = CardDefaults.cardColors(containerColor = config.containerColor) + ) { + Column( + modifier = Modifier.padding(Dimens.spacingBig), + verticalArrangement = Arrangement.Center + ) { + Text( + text = config.headerText, + style = config.headerTextStyle, + color = config.headerTextColor + ) + + Spacer(modifier = Modifier.height(Dimens.spacingBig)) + + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceAround, + verticalAlignment = Alignment.CenterVertically + ) { + Column( + modifier = Modifier.weight(1f), + horizontalAlignment = Alignment.CenterHorizontally + ) { + Box( + modifier = Modifier + .clip(RoundedCornerShape(Dimens.cornerRadiusNormal)) + .clickable { + onCameraSelectClick() + onDismiss() + }, + contentAlignment = Alignment.Center + ) { + config.iconCamera() + } + + Spacer(modifier = Modifier.height(Dimens.spacingNormalSpecial)) + + Text( + text = config.cameraText, + style = config.labelTextStyle, + color = config.labelTextColor + ) + } + + Column( + modifier = Modifier.weight(1f), + horizontalAlignment = Alignment.CenterHorizontally + ) { + Box( + modifier = Modifier + .clip(RoundedCornerShape(Dimens.cornerRadiusNormal)) + .clickable { + onGallerySelectClick() + onDismiss() + }, + contentAlignment = Alignment.Center + ) { + config.iconGallery() + } + + Spacer(modifier = Modifier.height(Dimens.spacingNormalSpecial)) + + Text( + text = config.galleryText, + style = config.labelTextStyle, + color = config.labelTextColor + ) + } + } + + Spacer(modifier = Modifier.height(Dimens.spacingBig)) + + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.End + ) { + Box( + modifier = Modifier + .clip(RoundedCornerShape(Dimens.cornerRadiusNormal)) + .clickable { onDismiss() }, + contentAlignment = Alignment.Center + ) { + Text( + modifier = Modifier.padding(Dimens.spacingNormalSpecial), + text = config.onDismissText, + style = config.cancelTextStyle, + color = config.cancelTextColor + ) + } + } + } + } + } +} diff --git a/design/src/main/java/com/urlaunched/android/design/ui/imagePicker/PhotoSize.kt b/design/src/main/java/com/urlaunched/android/design/ui/imagePicker/PhotoSize.kt new file mode 100644 index 00000000..60d40f1e --- /dev/null +++ b/design/src/main/java/com/urlaunched/android/design/ui/imagePicker/PhotoSize.kt @@ -0,0 +1,19 @@ +package com.urlaunched.android.design.ui.imagePicker + +enum class PhotoSize(val value: Int) { + MB_1(1), + MB_2(2), + MB_3(3), + MB_4(4), + MB_5(5), + MB_6(6), + MB_7(7), + MB_8(8), + MB_9(9), + MB_10(10), + MB_11(11), + MB_12(12), + MB_13(13), + MB_14(14), + MB_15(15) +} \ No newline at end of file diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index f5bcf8ab..d0280cc6 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -184,6 +184,7 @@ firebaseDependencies-remoteconfig = { module = "com.google.firebase:firebase-con firebaseDependencies-performance = { module = "com.google.firebase:firebase-perf" } composeAccompanist-navigation = { module = "com.google.accompanist:accompanist-navigation-material", version.ref = "accompanistVersion" } +composeAccompanist-permissions = { module = "com.google.accompanist:accompanist-permissions", version.ref = "accompanistVersion" } paparazi-core = { module = "app.cash.paparazzi:paparazzi", version.ref = "paparazziPluginVersion"} junit = { group = "androidx.test.ext", name = "junit", version.ref = "junit" } From 73191ae5b3240fa39de9c61ad22aebe70cda2e38 Mon Sep 17 00:00:00 2001 From: BohunD Date: Thu, 17 Jul 2025 18:09:53 +0300 Subject: [PATCH 2/5] moved error strings to parameters --- .../design/ui/imagePicker/ImagePicker.kt | 48 +++++++++++++++---- .../ui/imagePicker/ImagePickerDelegate.kt | 15 ------ .../ui/imagePicker/ImagePickerDelegateImpl.kt | 25 ---------- 3 files changed, 40 insertions(+), 48 deletions(-) delete mode 100644 design/src/main/java/com/urlaunched/android/design/ui/imagePicker/ImagePickerDelegate.kt delete mode 100644 design/src/main/java/com/urlaunched/android/design/ui/imagePicker/ImagePickerDelegateImpl.kt diff --git a/design/src/main/java/com/urlaunched/android/design/ui/imagePicker/ImagePicker.kt b/design/src/main/java/com/urlaunched/android/design/ui/imagePicker/ImagePicker.kt index 2c1951e1..cf61accc 100644 --- a/design/src/main/java/com/urlaunched/android/design/ui/imagePicker/ImagePicker.kt +++ b/design/src/main/java/com/urlaunched/android/design/ui/imagePicker/ImagePicker.kt @@ -41,6 +41,11 @@ fun ImagePicker( deleteTempFilesWhenOnDispose: Boolean = true, maxFiles: Int = ImagePickerConstants.MAX_PHOTOS_AMOUNT, needToPickMultipleFiles: Boolean = false, + compressionTargetSize: Long = 5 * 1000 * 1000, + compressionMinWidth: Int = 1920, + compressionMinHeight: Int = 1080, + photoTooLargeMessage: String = "Photo must be less than 15 MB", + compressionFailedMessage: String = "Something went wrong", showSnackbar: suspend (message: String) -> Unit, setIsCompressionProceed: (isCompressionProceed: Boolean) -> Unit = {}, content: @Composable (onClick: () -> Unit) -> Unit @@ -63,8 +68,14 @@ fun ImagePicker( coroutineScope = coroutineScope, onFileChanges = onFilesChanges, showSnackbar = showSnackbar, - setIsCompressionProceed = setIsCompressionProceed + setIsCompressionProceed = setIsCompressionProceed, + compressionTargetSize = compressionTargetSize, + compressionMinWidth = compressionMinWidth, + compressionMinHeight = compressionMinHeight, + photoTooLargeMessage = photoTooLargeMessage, + compressionFailedMessage = compressionFailedMessage ) + } } ) @@ -80,7 +91,12 @@ fun ImagePicker( coroutineScope = coroutineScope, onFileChanges = onFilesChanges, showSnackbar = showSnackbar, - setIsCompressionProceed = setIsCompressionProceed + setIsCompressionProceed = setIsCompressionProceed, + compressionTargetSize = compressionTargetSize, + compressionMinWidth = compressionMinWidth, + compressionMinHeight = compressionMinHeight, + photoTooLargeMessage = photoTooLargeMessage, + compressionFailedMessage = compressionFailedMessage ) } } @@ -103,7 +119,12 @@ fun ImagePicker( coroutineScope = coroutineScope, onFileChanges = onFilesChanges, showSnackbar = showSnackbar, - setIsCompressionProceed = setIsCompressionProceed + setIsCompressionProceed = setIsCompressionProceed, + compressionTargetSize = compressionTargetSize, + compressionMinWidth = compressionMinWidth, + compressionMinHeight = compressionMinHeight, + photoTooLargeMessage = photoTooLargeMessage, + compressionFailedMessage = compressionFailedMessage ) } ) @@ -166,7 +187,12 @@ private fun validateFilesAndCompress( context: Context, coroutineScope: CoroutineScope, showSnackbar: suspend (message: String) -> Unit, - setIsCompressionProceed: (isUnderCompression: Boolean) -> Unit + setIsCompressionProceed: (isUnderCompression: Boolean) -> Unit, + compressionTargetSize: Long, + compressionMinWidth: Int, + compressionMinHeight: Int, + photoTooLargeMessage: String, + compressionFailedMessage: String ) { val maxImageSize = 15 * 1024 * 1024 val targetImageSize = 5 * 1000 * 1000 @@ -179,16 +205,22 @@ private fun validateFilesAndCompress( files.forEach { file -> if (file.length() > maxImageSize) { - showSnackbar("Photo must be less 15 mb") + showSnackbar(photoTooLargeMessage) } else if (file.length() < targetImageSize) { validFiles.add(file) } else { try { - val compressedFile = CompressImageUtil.compressImage(file, context) + val compressedFile = CompressImageUtil.compressImage( + input = file, + context = context, + targetSize = compressionTargetSize, + minWidth = compressionMinWidth, + minHeight = compressionMinHeight + ) compressedFile?.let { compressedFiles.add(it) } - ?: showSnackbar("Something went wrong") + ?: showSnackbar(compressionFailedMessage) } catch (e: Exception) { - showSnackbar("Something went wrong") + showSnackbar(compressionFailedMessage) return@launch } } diff --git a/design/src/main/java/com/urlaunched/android/design/ui/imagePicker/ImagePickerDelegate.kt b/design/src/main/java/com/urlaunched/android/design/ui/imagePicker/ImagePickerDelegate.kt deleted file mode 100644 index 16d397b7..00000000 --- a/design/src/main/java/com/urlaunched/android/design/ui/imagePicker/ImagePickerDelegate.kt +++ /dev/null @@ -1,15 +0,0 @@ -package com.urlaunched.android.design.ui.imagePicker - -import androidx.compose.ui.text.input.TextFieldValue -import kotlinx.coroutines.flow.StateFlow -import java.io.File - -interface ImagePickerDelegate { - val uiImagePickerStage: StateFlow - - fun selectFile(file: File?) -} - -data class ImagePickerDelegateState( - val selectedFile: File?, -) \ No newline at end of file diff --git a/design/src/main/java/com/urlaunched/android/design/ui/imagePicker/ImagePickerDelegateImpl.kt b/design/src/main/java/com/urlaunched/android/design/ui/imagePicker/ImagePickerDelegateImpl.kt deleted file mode 100644 index 604874f9..00000000 --- a/design/src/main/java/com/urlaunched/android/design/ui/imagePicker/ImagePickerDelegateImpl.kt +++ /dev/null @@ -1,25 +0,0 @@ -package com.urlaunched.android.design.ui.imagePicker - -import androidx.compose.ui.text.TextRange -import androidx.compose.ui.text.input.TextFieldValue -import com.urlaunched.android.design.delegate.Delegate -import com.urlaunched.android.design.ui.textfield.hashtags.HashtagsDelegate -import com.urlaunched.android.design.ui.textfield.hashtags.HashtagsDelegateState -import kotlinx.coroutines.CoroutineDispatcher -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.update -import java.io.File - -class ImagePickerDelegateImpl( - private val coroutineDispatcher: CoroutineDispatcher -) : Delegate(coroutineDispatcher), ImagePickerDelegate { - private val _uiState = MutableStateFlow(ImagePickerDelegateState(selectedFile = null)) - - override val uiImagePickerStage: StateFlow = _uiState - override fun selectFile(file: File?) { - _uiState.update { uiState -> - uiState.copy(selectedFile = file) - } - } -} \ No newline at end of file From 07b0cc72bc060a3f4db924ef1726752b7507bd40 Mon Sep 17 00:00:00 2001 From: Khotych Mykola Date: Mon, 20 Oct 2025 17:25:49 +0300 Subject: [PATCH 3/5] Add material icons core dependency --- design/build.gradle | 1 + gradle/libs.versions.toml | 1 + 2 files changed, 2 insertions(+) diff --git a/design/build.gradle b/design/build.gradle index c304d73a..b0435c9f 100644 --- a/design/build.gradle +++ b/design/build.gradle @@ -69,6 +69,7 @@ dependencies { implementation libs.composeDependencies.composePreview implementation libs.composeDependencies.composeMaterial3 implementation libs.composeDependencies.composeMaterial2 + implementation libs.composeDependencies.composeMaterialIconsCore implementation libs.composeDependencies.composeNavigation implementation libs.composeDependencies.composeConstraintLayout implementation libs.composeDependencies.lifecycleRuntime diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index d0280cc6..8739189c 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -121,6 +121,7 @@ composeDependencies-composeUi = { module = "androidx.compose.ui:ui", version.ref composeDependencies-composePreview = { module = "androidx.compose.ui:ui-tooling-preview", version.ref = "composeVersion" } composeDependencies-composeMaterial3 = { module = "androidx.compose.material3:material3", version.ref = "composeMaterial3Version" } composeDependencies-composeMaterial2 = { module = "androidx.compose.material:material", version.ref = "composeMaterial2Version" } +composeDependencies-composeMaterialIconsCore = { module = "androidx.compose.material:material-icons-core", version.ref = "composeVersion" } composeDependencies-composeNavigation = { module = "androidx.navigation:navigation-compose", version.ref = "composeNavigationVersion" } composeDependencies-composeConstraintLayout = { module = "androidx.constraintlayout:constraintlayout-compose", version.ref = "composeConstraintLayout" } composeDependencies-composeUiTooling = { module = "androidx.compose.ui:ui-tooling", version.ref = "composeVersion" } From d517b6dc0c807ecd5cbecb53f2d995b148dfe32c Mon Sep 17 00:00:00 2001 From: Khotych Mykola Date: Wed, 8 Oct 2025 17:38:45 +0300 Subject: [PATCH 4/5] Update agp to 8.6.0 --- gradle/libs.versions.toml | 4 ++-- gradle/wrapper/gradle-wrapper.properties | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 8739189c..f235bef2 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -87,8 +87,8 @@ bottomSheetVersion = "1.33.0" # plugins # dependencies -androidApplicationPluginVersion = "8.4.2" -androidLibraryPluginVersion = "8.4.2" +androidApplicationPluginVersion = "8.6.0" +androidLibraryPluginVersion = "8.6.0" kotlinAndroidPluginVersion = "2.1.20" kotlinJvmPluginVersion = "2.1.20" kotlinSerializationPluginVersion = "2.1.20" diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties index b82aa23a..a4413138 100644 --- a/gradle/wrapper/gradle-wrapper.properties +++ b/gradle/wrapper/gradle-wrapper.properties @@ -1,6 +1,6 @@ distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-8.7-bin.zip +distributionUrl=https\://services.gradle.org/distributions/gradle-8.8-bin.zip networkTimeout=10000 validateDistributionUrl=true zipStoreBase=GRADLE_USER_HOME From 502f5db3beb04981cac72a6cd6711fbad9e2f2e9 Mon Sep 17 00:00:00 2001 From: Khotych Mykola Date: Thu, 23 Oct 2025 16:37:56 +0300 Subject: [PATCH 5/5] Refactor image picker --- .../design/ui/imagePicker/ImagePicker.kt | 244 +++++++----------- .../ui/imagePicker/ImagePickerConstants.kt | 5 - .../ui/imagePicker/ImagePickerDialogConfig.kt | 24 -- .../imagePicker/ImagePickerSelectorDialog.kt | 128 --------- .../design/ui/imagePicker/ImagePickerState.kt | 139 ++++++++++ .../imagePicker/ImageSourceSelectorDialog.kt | 162 ++++++++++++ .../design/ui/imagePicker/PhotoSize.kt | 19 -- .../dimens/CompressionConstants.kt | 8 + .../imagePicker/dimens/ImagePickerDimens.kt | 17 ++ .../ui/imagePicker/model/CompressionConfig.kt | 11 + .../model/ImagePickerDialogStyle.kt | 15 ++ .../ui/imagePicker/model/ImagePickerError.kt | 6 + .../model/ImagePickerTextStyles.kt | 9 + .../ui/imagePicker/model/ImageSource.kt | 29 +++ .../ui/imagePicker/model/ImagesCount.kt | 20 ++ design/src/main/res/drawable/ic_camera.xml | 17 ++ design/src/main/res/drawable/ic_gallery.xml | 17 ++ 17 files changed, 549 insertions(+), 321 deletions(-) delete mode 100644 design/src/main/java/com/urlaunched/android/design/ui/imagePicker/ImagePickerConstants.kt delete mode 100644 design/src/main/java/com/urlaunched/android/design/ui/imagePicker/ImagePickerDialogConfig.kt delete mode 100644 design/src/main/java/com/urlaunched/android/design/ui/imagePicker/ImagePickerSelectorDialog.kt create mode 100644 design/src/main/java/com/urlaunched/android/design/ui/imagePicker/ImagePickerState.kt create mode 100644 design/src/main/java/com/urlaunched/android/design/ui/imagePicker/ImageSourceSelectorDialog.kt delete mode 100644 design/src/main/java/com/urlaunched/android/design/ui/imagePicker/PhotoSize.kt create mode 100644 design/src/main/java/com/urlaunched/android/design/ui/imagePicker/dimens/CompressionConstants.kt create mode 100644 design/src/main/java/com/urlaunched/android/design/ui/imagePicker/dimens/ImagePickerDimens.kt create mode 100644 design/src/main/java/com/urlaunched/android/design/ui/imagePicker/model/CompressionConfig.kt create mode 100644 design/src/main/java/com/urlaunched/android/design/ui/imagePicker/model/ImagePickerDialogStyle.kt create mode 100644 design/src/main/java/com/urlaunched/android/design/ui/imagePicker/model/ImagePickerError.kt create mode 100644 design/src/main/java/com/urlaunched/android/design/ui/imagePicker/model/ImagePickerTextStyles.kt create mode 100644 design/src/main/java/com/urlaunched/android/design/ui/imagePicker/model/ImageSource.kt create mode 100644 design/src/main/java/com/urlaunched/android/design/ui/imagePicker/model/ImagesCount.kt create mode 100644 design/src/main/res/drawable/ic_camera.xml create mode 100644 design/src/main/res/drawable/ic_gallery.xml diff --git a/design/src/main/java/com/urlaunched/android/design/ui/imagePicker/ImagePicker.kt b/design/src/main/java/com/urlaunched/android/design/ui/imagePicker/ImagePicker.kt index cf61accc..0cd75d3d 100644 --- a/design/src/main/java/com/urlaunched/android/design/ui/imagePicker/ImagePicker.kt +++ b/design/src/main/java/com/urlaunched/android/design/ui/imagePicker/ImagePicker.kt @@ -1,81 +1,70 @@ -package com.urlaunched.android.design.ui.imagePicker +package com.urlaunched.android.design.ui.imagepicker import android.Manifest -import android.content.Context import android.net.Uri import androidx.activity.compose.ManagedActivityResultLauncher import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.result.PickVisualMediaRequest import androidx.activity.result.contract.ActivityResultContracts +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Button +import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember -import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalInspectionMode +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp import com.google.accompanist.permissions.ExperimentalPermissionsApi import com.google.accompanist.permissions.isGranted import com.google.accompanist.permissions.rememberPermissionState -import com.urlaunched.android.common.compression.CompressImageUtil import com.urlaunched.android.common.files.FileHelper -import com.urlaunched.android.common.files.FilePickerHelper import com.urlaunched.android.common.files.TakeCameraPictureContract -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.launch +import com.urlaunched.android.design.resources.dimens.Dimens +import com.urlaunched.android.design.ui.image.UrlImage +import com.urlaunched.android.design.ui.imagepicker.model.ImagePickerDialogStyle +import com.urlaunched.android.design.ui.imagepicker.model.ImagePickerTextStyles +import com.urlaunched.android.design.ui.imagepicker.model.ImageSource +import com.urlaunched.android.design.ui.imagepicker.model.ImagesCount import java.io.File private const val FILE_PROVIDER = ".provider" @OptIn(ExperimentalPermissionsApi::class) -@Suppress("ktlint:ktlintrules:composable-modifier-missing-rule") @Composable fun ImagePicker( - dialogModifier: Modifier = Modifier, - dialogConfig: ImagePickerDialogConfig, - onFilesChanges: ((file: List) -> Unit)?, - deleteTempFilesWhenOnDispose: Boolean = true, - maxFiles: Int = ImagePickerConstants.MAX_PHOTOS_AMOUNT, - needToPickMultipleFiles: Boolean = false, - compressionTargetSize: Long = 5 * 1000 * 1000, - compressionMinWidth: Int = 1920, - compressionMinHeight: Int = 1080, - photoTooLargeMessage: String = "Photo must be less than 15 MB", - compressionFailedMessage: String = "Something went wrong", - showSnackbar: suspend (message: String) -> Unit, - setIsCompressionProceed: (isCompressionProceed: Boolean) -> Unit = {}, - content: @Composable (onClick: () -> Unit) -> Unit + state: ImagePickerState, + gallerySource: ImageSource.Gallery, + cameraSource: ImageSource.Camera, + headlineText: String, + dismissButtonText: String, + style: ImagePickerDialogStyle = ImagePickerDialogStyle(), + textStyles: ImagePickerTextStyles = ImagePickerTextStyles() ) { if (!LocalInspectionMode.current) { val context = LocalContext.current - val coroutineScope = rememberCoroutineScope() - var showSelectorDialog by remember { mutableStateOf(false) } - - val cameraPickerContract = - remember { TakeCameraPictureContract(fileProviderAuthority = context.packageName + FILE_PROVIDER) } + val cameraPickerContract = remember { + TakeCameraPictureContract(fileProviderAuthority = context.packageName + FILE_PROVIDER) + } val cameraLauncher = rememberLauncherForActivityResult( contract = cameraPickerContract, onResult = { file -> if (file != null) { - validateFilesAndCompress( - files = listOf(file), - context = context, - coroutineScope = coroutineScope, - onFileChanges = onFilesChanges, - showSnackbar = showSnackbar, - setIsCompressionProceed = setIsCompressionProceed, - compressionTargetSize = compressionTargetSize, - compressionMinWidth = compressionMinWidth, - compressionMinHeight = compressionMinHeight, - photoTooLargeMessage = photoTooLargeMessage, - compressionFailedMessage = compressionFailedMessage - ) - + state.processPickedFiles(files = listOf(file), context = context) } } ) @@ -83,49 +72,17 @@ fun ImagePicker( val pickerLauncher = rememberLauncherForActivityResult( contract = ActivityResultContracts.PickVisualMedia(), onResult = { uri -> - uri?.let { - FilePickerHelper.createFileFromUri(context, it)?.let { file -> - validateFilesAndCompress( - files = listOf(file), - context = context, - coroutineScope = coroutineScope, - onFileChanges = onFilesChanges, - showSnackbar = showSnackbar, - setIsCompressionProceed = setIsCompressionProceed, - compressionTargetSize = compressionTargetSize, - compressionMinWidth = compressionMinWidth, - compressionMinHeight = compressionMinHeight, - photoTooLargeMessage = photoTooLargeMessage, - compressionFailedMessage = compressionFailedMessage - ) - } + if (uri != null) { + state.processPickedUris(uris = listOf(uri), context = context) } } ) - val multiplePhotoPickerLauncher = if (maxFiles > 1) { + val multiplePhotoPickerLauncher = if (state.maxImagesCount > ImagesCount.SINGLE_IMAGE) { rememberLauncherForActivityResult( - contract = ActivityResultContracts.PickMultipleVisualMedia(maxItems = maxFiles), + contract = ActivityResultContracts.PickMultipleVisualMedia(maxItems = state.maxImagesCount.count), onResult = { uris -> - val files = mutableListOf() - uris.forEach { - FilePickerHelper.createFileFromUri(context, it)?.let { file -> - files.add(file) - } - } - validateFilesAndCompress( - files = files, - context = context, - coroutineScope = coroutineScope, - onFileChanges = onFilesChanges, - showSnackbar = showSnackbar, - setIsCompressionProceed = setIsCompressionProceed, - compressionTargetSize = compressionTargetSize, - compressionMinWidth = compressionMinWidth, - compressionMinHeight = compressionMinHeight, - photoTooLargeMessage = photoTooLargeMessage, - compressionFailedMessage = compressionFailedMessage - ) + state.processPickedUris(uris = uris, context = context) } ) } else { @@ -135,14 +92,15 @@ fun ImagePicker( val cameraPermissionState = rememberPermissionState(permission = Manifest.permission.CAMERA) val cameraRequestPermissionLauncher = rememberLauncherForActivityResult( - ActivityResultContracts.RequestPermission() - ) { granted -> - if (granted) { - launchCameraPicker(cameraLauncher) + contract = ActivityResultContracts.RequestPermission(), + onResult = { granted -> + if (granted) { + launchCameraPicker(cameraLauncher) + } } - } + ) - if (deleteTempFilesWhenOnDispose) { + if (state.deleteTempFilesOnDispose) { DisposableEffect(Unit) { onDispose { FileHelper.deleteTempFilesFromCache(context) @@ -150,85 +108,81 @@ fun ImagePicker( } } - content { - showSelectorDialog = true - } - - if (showSelectorDialog) { - ImagePickerSelectorDialog( - - onGallerySelectClick = { - if (needToPickMultipleFiles && maxFiles > 1 && multiplePhotoPickerLauncher != null) { + if (state.isSourceSelectorDialogShown) { + ImageSourceSelectorDialog( + onGalleryClick = { + if (state.maxImagesCount > ImagesCount.SINGLE_IMAGE && multiplePhotoPickerLauncher != null) { launchMultipleImagePicker(multiplePhotoPickerLauncher) } else { launchImagePicker(pickerLauncher) } }, - onCameraSelectClick = { + onCameraClick = { if (!cameraPermissionState.status.isGranted) { cameraRequestPermissionLauncher.launch(Manifest.permission.CAMERA) } else { launchCameraPicker(cameraLauncher) } }, - onDismiss = { showSelectorDialog = false }, - modifier = dialogModifier, - config = dialogConfig + onDismiss = state::hideSourceSelectorDialog, + cameraSource = cameraSource, + gallerySource = gallerySource, + headlineText = headlineText, + dismissButtonText = dismissButtonText, + style = style, + textStyles = textStyles ) } - } else { - content {} } } -private fun validateFilesAndCompress( - files: List, - onFileChanges: ((file: List) -> Unit)?, - context: Context, - coroutineScope: CoroutineScope, - showSnackbar: suspend (message: String) -> Unit, - setIsCompressionProceed: (isUnderCompression: Boolean) -> Unit, - compressionTargetSize: Long, - compressionMinWidth: Int, - compressionMinHeight: Int, - photoTooLargeMessage: String, - compressionFailedMessage: String -) { - val maxImageSize = 15 * 1024 * 1024 - val targetImageSize = 5 * 1000 * 1000 - - val validFiles = mutableListOf() - val compressedFiles = mutableListOf() - - coroutineScope.launch(Dispatchers.IO) { - setIsCompressionProceed(true) - - files.forEach { file -> - if (file.length() > maxImageSize) { - showSnackbar(photoTooLargeMessage) - } else if (file.length() < targetImageSize) { - validFiles.add(file) - } else { - try { - val compressedFile = CompressImageUtil.compressImage( - input = file, - context = context, - targetSize = compressionTargetSize, - minWidth = compressionMinWidth, - minHeight = compressionMinHeight - ) - compressedFile?.let { compressedFiles.add(it) } - ?: showSnackbar(compressionFailedMessage) - } catch (e: Exception) { - showSnackbar(compressionFailedMessage) - return@launch - } +@Preview(showBackground = true) +@Composable +private fun ImagePickerPreview() { + var pickedImage by remember { mutableStateOf>(emptyList()) } + val imagePickerState = rememberImagePickerState( + onError = {}, + onImagesPicked = { images -> + pickedImage += images + } + ) + + ImagePicker( + state = imagePickerState, + cameraSource = ImageSource.Camera(title = "Camera"), + gallerySource = ImageSource.Gallery(title = "Gallery"), + headlineText = "Choose source", + dismissButtonText = "Cancel", + ) + + Column( + modifier = Modifier.fillMaxSize(), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(space = Dimens.spacingBig, alignment = Alignment.CenterVertically) + ) { + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(space = Dimens.spacingNormal, alignment = Alignment.CenterHorizontally) + ) { + pickedImage.forEach { image -> + UrlImage( + model = image, + modifier = Modifier + .size(100.dp) + .clip(RoundedCornerShape(Dimens.cornerRadiusNormal)) + ) } } - validFiles.addAll(compressedFiles) - onFileChanges?.invoke(validFiles) - setIsCompressionProceed(false) + Button( + enabled = pickedImage.size < 3, + onClick = { + imagePickerState.showSourceSelectorDialog(ImagesCount(count = 3 - pickedImage.size)) + } + ) { + Text("Pick images") + } } } diff --git a/design/src/main/java/com/urlaunched/android/design/ui/imagePicker/ImagePickerConstants.kt b/design/src/main/java/com/urlaunched/android/design/ui/imagePicker/ImagePickerConstants.kt deleted file mode 100644 index 20ea088c..00000000 --- a/design/src/main/java/com/urlaunched/android/design/ui/imagePicker/ImagePickerConstants.kt +++ /dev/null @@ -1,5 +0,0 @@ -package com.urlaunched.android.design.ui.imagePicker - -internal object ImagePickerConstants { - const val MAX_PHOTOS_AMOUNT = 6 -} \ No newline at end of file diff --git a/design/src/main/java/com/urlaunched/android/design/ui/imagePicker/ImagePickerDialogConfig.kt b/design/src/main/java/com/urlaunched/android/design/ui/imagePicker/ImagePickerDialogConfig.kt deleted file mode 100644 index 24131f6d..00000000 --- a/design/src/main/java/com/urlaunched/android/design/ui/imagePicker/ImagePickerDialogConfig.kt +++ /dev/null @@ -1,24 +0,0 @@ -package com.urlaunched.android.design.ui.imagePicker - -import androidx.compose.runtime.Composable -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.text.TextStyle - -data class ImagePickerDialogConfig( - val headerText: String, - val cameraText: String, - val galleryText: String, - val onDismissText: String = "Cancel", - - val headerTextStyle: TextStyle, - val labelTextStyle: TextStyle, - val cancelTextStyle: TextStyle, - - val iconCamera: @Composable () -> Unit, - val iconGallery: @Composable () -> Unit, - - val containerColor: Color, - val headerTextColor: Color, - val labelTextColor: Color, - val cancelTextColor: Color -) diff --git a/design/src/main/java/com/urlaunched/android/design/ui/imagePicker/ImagePickerSelectorDialog.kt b/design/src/main/java/com/urlaunched/android/design/ui/imagePicker/ImagePickerSelectorDialog.kt deleted file mode 100644 index c856c87d..00000000 --- a/design/src/main/java/com/urlaunched/android/design/ui/imagePicker/ImagePickerSelectorDialog.kt +++ /dev/null @@ -1,128 +0,0 @@ -package com.urlaunched.android.design.ui.imagePicker - -import androidx.compose.foundation.clickable -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.material3.Card -import androidx.compose.material3.CardDefaults -import androidx.compose.material3.Text -import androidx.compose.runtime.Composable -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.clip -import androidx.compose.ui.window.Dialog -import com.urlaunched.android.design.resources.dimens.Dimens - -@Composable -fun ImagePickerSelectorDialog( - modifier: Modifier = Modifier, - config: ImagePickerDialogConfig, - onGallerySelectClick: () -> Unit, - onCameraSelectClick: () -> Unit, - onDismiss: () -> Unit -) { - Dialog(onDismissRequest = { onDismiss() }) { - Card( - modifier = modifier, - shape = RoundedCornerShape(Dimens.cornerRadiusNormal), - colors = CardDefaults.cardColors(containerColor = config.containerColor) - ) { - Column( - modifier = Modifier.padding(Dimens.spacingBig), - verticalArrangement = Arrangement.Center - ) { - Text( - text = config.headerText, - style = config.headerTextStyle, - color = config.headerTextColor - ) - - Spacer(modifier = Modifier.height(Dimens.spacingBig)) - - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.SpaceAround, - verticalAlignment = Alignment.CenterVertically - ) { - Column( - modifier = Modifier.weight(1f), - horizontalAlignment = Alignment.CenterHorizontally - ) { - Box( - modifier = Modifier - .clip(RoundedCornerShape(Dimens.cornerRadiusNormal)) - .clickable { - onCameraSelectClick() - onDismiss() - }, - contentAlignment = Alignment.Center - ) { - config.iconCamera() - } - - Spacer(modifier = Modifier.height(Dimens.spacingNormalSpecial)) - - Text( - text = config.cameraText, - style = config.labelTextStyle, - color = config.labelTextColor - ) - } - - Column( - modifier = Modifier.weight(1f), - horizontalAlignment = Alignment.CenterHorizontally - ) { - Box( - modifier = Modifier - .clip(RoundedCornerShape(Dimens.cornerRadiusNormal)) - .clickable { - onGallerySelectClick() - onDismiss() - }, - contentAlignment = Alignment.Center - ) { - config.iconGallery() - } - - Spacer(modifier = Modifier.height(Dimens.spacingNormalSpecial)) - - Text( - text = config.galleryText, - style = config.labelTextStyle, - color = config.labelTextColor - ) - } - } - - Spacer(modifier = Modifier.height(Dimens.spacingBig)) - - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.End - ) { - Box( - modifier = Modifier - .clip(RoundedCornerShape(Dimens.cornerRadiusNormal)) - .clickable { onDismiss() }, - contentAlignment = Alignment.Center - ) { - Text( - modifier = Modifier.padding(Dimens.spacingNormalSpecial), - text = config.onDismissText, - style = config.cancelTextStyle, - color = config.cancelTextColor - ) - } - } - } - } - } -} diff --git a/design/src/main/java/com/urlaunched/android/design/ui/imagePicker/ImagePickerState.kt b/design/src/main/java/com/urlaunched/android/design/ui/imagePicker/ImagePickerState.kt new file mode 100644 index 00000000..2e5e0c64 --- /dev/null +++ b/design/src/main/java/com/urlaunched/android/design/ui/imagePicker/ImagePickerState.kt @@ -0,0 +1,139 @@ +package com.urlaunched.android.design.ui.imagepicker + +import android.content.Context +import android.net.Uri +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import com.urlaunched.android.common.compression.CompressImageUtil +import com.urlaunched.android.common.files.FilePickerHelper +import com.urlaunched.android.design.ui.imagepicker.model.CompressionConfig +import com.urlaunched.android.design.ui.imagepicker.model.ImagePickerError +import com.urlaunched.android.design.ui.imagepicker.model.ImagesCount +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import java.io.File + +class ImagePickerState( + val compressionConfig: CompressionConfig = CompressionConfig(), + val coroutineScope: CoroutineScope, + val deleteTempFilesOnDispose: Boolean, + val onError: suspend (error: ImagePickerError) -> Unit, + val onImagesPicked: (images: List) -> Unit +) { + var maxImagesCount by mutableStateOf(ImagesCount.SINGLE_IMAGE) + private set + + var isSourceSelectorDialogShown by mutableStateOf(false) + private set + + var isFileCopyingProceed by mutableStateOf(false) + private set + + var isImageCompressionProceed by mutableStateOf(false) + private set + + fun processPickedUris(uris: List, context: Context) { + coroutineScope.launch(Dispatchers.IO) { + isFileCopyingProceed = true + + val files = uris.mapNotNull { uri -> + FilePickerHelper.createFileFromUri(context, uri) + } + + isFileCopyingProceed = false + + processPickedFiles(files = files, context = context) + } + } + + fun processPickedFiles(files: List, context: Context) { + coroutineScope.launch(Dispatchers.IO) { + isImageCompressionProceed = true + val validFiles = mutableListOf() + + files.forEach { file -> + if (file.length() > compressionConfig.maxSizeBytes) { + onError(ImagePickerError.PHOTO_TOO_LARGE) + } else { + try { + compressImage(file, context)?.let { compressedFile -> + validFiles.add(compressedFile) + } ?: onError(ImagePickerError.COMPRESSION_FAILED) + } catch (e: Exception) { + onError(ImagePickerError.COMPRESSION_FAILED) + isImageCompressionProceed = false + return@launch + } + } + } + + onImagesPicked(validFiles) + isImageCompressionProceed = false + } + } + + private fun compressImage(image: File, context: Context): File? { + return CompressImageUtil.compressImage( + input = image, + context = context, + targetSize = compressionConfig.targetSizeBytes, + minWidth = compressionConfig.minWidth, + minHeight = compressionConfig.minHeight + ) + } + + fun showSourceSelectorDialog( + maxImagesCount: ImagesCount = ImagesCount.SINGLE_IMAGE + ) { + this.maxImagesCount = maxImagesCount + isSourceSelectorDialogShown = true + } + + fun hideSourceSelectorDialog() { + isSourceSelectorDialogShown = false + } +} + +@Composable +fun rememberImagePickerState( + photoTooLargeErrorMessage: String, + compressionErrorMessage: String, + compressionConfig: CompressionConfig = CompressionConfig(), + coroutineScope: CoroutineScope = rememberCoroutineScope(), + deleteTempFilesOnDispose: Boolean = true, + showErrorSnackbar: suspend (message: String) -> Unit, + onImagesPicked: (images: List) -> Unit +) = rememberImagePickerState( + compressionConfig = compressionConfig, + coroutineScope = coroutineScope, + deleteTempFilesOnDispose = deleteTempFilesOnDispose, + onImagesPicked = onImagesPicked, + onError = { error -> + when (error) { + ImagePickerError.PHOTO_TOO_LARGE -> showErrorSnackbar(photoTooLargeErrorMessage) + ImagePickerError.COMPRESSION_FAILED -> showErrorSnackbar(compressionErrorMessage) + } + } +) + +@Composable +internal fun rememberImagePickerState( + compressionConfig: CompressionConfig = CompressionConfig(), + coroutineScope: CoroutineScope = rememberCoroutineScope(), + deleteTempFilesOnDispose: Boolean = true, + onError: suspend (error: ImagePickerError) -> Unit, + onImagesPicked: (images: List) -> Unit +) = remember { + ImagePickerState( + compressionConfig = compressionConfig, + coroutineScope = coroutineScope, + onError = onError, + deleteTempFilesOnDispose = deleteTempFilesOnDispose, + onImagesPicked = onImagesPicked + ) +} \ No newline at end of file diff --git a/design/src/main/java/com/urlaunched/android/design/ui/imagePicker/ImageSourceSelectorDialog.kt b/design/src/main/java/com/urlaunched/android/design/ui/imagePicker/ImageSourceSelectorDialog.kt new file mode 100644 index 00000000..8a2bb299 --- /dev/null +++ b/design/src/main/java/com/urlaunched/android/design/ui/imagePicker/ImageSourceSelectorDialog.kt @@ -0,0 +1,162 @@ +package com.urlaunched.android.design.ui.imagepicker + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Icon +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.window.Dialog +import com.urlaunched.android.design.ui.clickable.debouncedClickable +import com.urlaunched.android.design.ui.imagepicker.dimens.ImagePickerDimens +import com.urlaunched.android.design.ui.imagepicker.model.ImagePickerDialogStyle +import com.urlaunched.android.design.ui.imagepicker.model.ImagePickerTextStyles +import com.urlaunched.android.design.ui.imagepicker.model.ImageSource + +@Composable +internal fun ImageSourceSelectorDialog( + onDismiss: () -> Unit, + gallerySource: ImageSource.Gallery, + cameraSource: ImageSource.Camera, + headlineText: String, + dismissButtonText: String, + style: ImagePickerDialogStyle = ImagePickerDialogStyle(), + textStyles: ImagePickerTextStyles = ImagePickerTextStyles(), + onGalleryClick: () -> Unit, + onCameraClick: () -> Unit +) { + Dialog( + onDismissRequest = onDismiss + ) { + Column( + modifier = Modifier + .background(style.containerColor, style.shape) + .padding(style.contentPadding), + verticalArrangement = Arrangement.Center + ) { + Text( + text = headlineText, + style = textStyles.headlineStyle + ) + + Spacer(modifier = Modifier.height(style.contentSpacing)) + + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceAround, + verticalAlignment = Alignment.CenterVertically + ) { + ImageSourceOption( + modifier = Modifier.weight(1f), + textStyle = textStyles.optionTextStyle, + imageSource = cameraSource, + onClick = { + onCameraClick() + onDismiss() + } + ) + + ImageSourceOption( + modifier = Modifier.weight(1f), + textStyle = textStyles.optionTextStyle, + imageSource = gallerySource, + onClick = { + onGalleryClick() + onDismiss() + } + ) + } + + Spacer(modifier = Modifier.height(style.contentSpacing)) + + DismissButton( + text = dismissButtonText, + textStyle = textStyles.dismissButtonStyle, + modifier = Modifier.align(Alignment.End), + onClick = onDismiss + ) + } + } +} + +@Composable +private fun DismissButton( + modifier: Modifier = Modifier, + text: String, + textStyle: TextStyle = TextStyle(), + onClick: () -> Unit +) { + Box( + modifier = modifier + .clip(RoundedCornerShape(ImagePickerDimens.dismissButtonCornerRadius)) + .debouncedClickable(onClick = onClick) + .padding(ImagePickerDimens.dismissButtonInnerPadding) + ) { + Text( + text = text, + style = textStyle + ) + } +} + +@Composable +private fun ImageSourceOption( + modifier: Modifier = Modifier, + imageSource: ImageSource, + textStyle: TextStyle = TextStyle(), + onClick: () -> Unit +) { + Column( + modifier = modifier, + horizontalAlignment = Alignment.CenterHorizontally + ) { + Box( + modifier = Modifier + .clip(RoundedCornerShape(ImagePickerDimens.imageSourceCornerRadius)) + .debouncedClickable(onClick = onClick), + contentAlignment = Alignment.Center + ) { + Icon( + painter = painterResource(imageSource.iconResId), + contentDescription = imageSource.contentDescription, + modifier = Modifier.size(ImagePickerDimens.imageSourceOptionsIconSize), + tint = imageSource.iconTint + ) + } + + Spacer(modifier = Modifier.height(ImagePickerDimens.imageSourceTitleSpacing)) + + Text( + text = imageSource.title, + style = textStyle + ) + } +} + +@Preview +@Composable +private fun ImageSourceSelectorDialogPreview() { + ImageSourceSelectorDialog( + onDismiss = {}, + cameraSource = ImageSource.Camera(title = "Camera"), + gallerySource = ImageSource.Gallery(title = "Gallery"), + headlineText = "Choose source", + dismissButtonText = "Cancel", + onGalleryClick = {}, + onCameraClick = {} + ) +} diff --git a/design/src/main/java/com/urlaunched/android/design/ui/imagePicker/PhotoSize.kt b/design/src/main/java/com/urlaunched/android/design/ui/imagePicker/PhotoSize.kt deleted file mode 100644 index 60d40f1e..00000000 --- a/design/src/main/java/com/urlaunched/android/design/ui/imagePicker/PhotoSize.kt +++ /dev/null @@ -1,19 +0,0 @@ -package com.urlaunched.android.design.ui.imagePicker - -enum class PhotoSize(val value: Int) { - MB_1(1), - MB_2(2), - MB_3(3), - MB_4(4), - MB_5(5), - MB_6(6), - MB_7(7), - MB_8(8), - MB_9(9), - MB_10(10), - MB_11(11), - MB_12(12), - MB_13(13), - MB_14(14), - MB_15(15) -} \ No newline at end of file diff --git a/design/src/main/java/com/urlaunched/android/design/ui/imagePicker/dimens/CompressionConstants.kt b/design/src/main/java/com/urlaunched/android/design/ui/imagePicker/dimens/CompressionConstants.kt new file mode 100644 index 00000000..153dd356 --- /dev/null +++ b/design/src/main/java/com/urlaunched/android/design/ui/imagePicker/dimens/CompressionConstants.kt @@ -0,0 +1,8 @@ +package com.urlaunched.android.design.ui.imagepicker.dimens + +internal object CompressionConstants { + const val DEFAULT_MAX_SIZE_BYTES: Long = 15 * 1024 * 1024 + const val DEFAULT_TARGET_SIZE_BYTES: Long = 5 * 1000 * 1000 + const val DEFAULT_MIN_HEIGHT = 1080 + const val DEFAULT_MIN_WIDTH = 1920 +} \ No newline at end of file diff --git a/design/src/main/java/com/urlaunched/android/design/ui/imagePicker/dimens/ImagePickerDimens.kt b/design/src/main/java/com/urlaunched/android/design/ui/imagePicker/dimens/ImagePickerDimens.kt new file mode 100644 index 00000000..d3f371f8 --- /dev/null +++ b/design/src/main/java/com/urlaunched/android/design/ui/imagePicker/dimens/ImagePickerDimens.kt @@ -0,0 +1,17 @@ +package com.urlaunched.android.design.ui.imagepicker.dimens + +import androidx.compose.ui.unit.dp +import com.urlaunched.android.design.resources.dimens.Dimens + +internal object ImagePickerDimens { + val imageSourceOptionsIconSize = 63.dp + val imageSourceCornerRadius = Dimens.cornerRadiusNormal + val imageSourceTitleSpacing = Dimens.spacingNormalSpecial + + val dismissButtonCornerRadius = Dimens.cornerRadiusNormal + val dismissButtonInnerPadding = Dimens.spacingNormalSpecial + + val imagePickerDialogCornerRadius = Dimens.cornerRadiusNormal + val imagePickerDialogContentPadding = Dimens.spacingBig + val imagePickerDialogContentSpacing = Dimens.spacingBig +} \ No newline at end of file diff --git a/design/src/main/java/com/urlaunched/android/design/ui/imagePicker/model/CompressionConfig.kt b/design/src/main/java/com/urlaunched/android/design/ui/imagePicker/model/CompressionConfig.kt new file mode 100644 index 00000000..c692bb8b --- /dev/null +++ b/design/src/main/java/com/urlaunched/android/design/ui/imagePicker/model/CompressionConfig.kt @@ -0,0 +1,11 @@ +package com.urlaunched.android.design.ui.imagepicker.model + +import com.urlaunched.android.design.ui.imagepicker.dimens.CompressionConstants + +data class CompressionConfig( + val minWidth: Int = CompressionConstants.DEFAULT_MIN_WIDTH, + val minHeight: Int = CompressionConstants.DEFAULT_MIN_HEIGHT, + val targetSizeBytes: Long = CompressionConstants.DEFAULT_TARGET_SIZE_BYTES, + val maxSizeBytes: Long = CompressionConstants.DEFAULT_MAX_SIZE_BYTES +) + diff --git a/design/src/main/java/com/urlaunched/android/design/ui/imagePicker/model/ImagePickerDialogStyle.kt b/design/src/main/java/com/urlaunched/android/design/ui/imagePicker/model/ImagePickerDialogStyle.kt new file mode 100644 index 00000000..9af7dcc5 --- /dev/null +++ b/design/src/main/java/com/urlaunched/android/design/ui/imagePicker/model/ImagePickerDialogStyle.kt @@ -0,0 +1,15 @@ +package com.urlaunched.android.design.ui.imagepicker.model + +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.Shape +import androidx.compose.ui.unit.Dp +import com.urlaunched.android.design.ui.imagepicker.dimens.ImagePickerDimens + +data class ImagePickerDialogStyle( + val shape: Shape = RoundedCornerShape(ImagePickerDimens.imagePickerDialogCornerRadius), + val containerColor: Color = Color.White, + val contentPadding: PaddingValues = PaddingValues(ImagePickerDimens.imagePickerDialogContentPadding), + val contentSpacing: Dp = ImagePickerDimens.imagePickerDialogContentSpacing +) \ No newline at end of file diff --git a/design/src/main/java/com/urlaunched/android/design/ui/imagePicker/model/ImagePickerError.kt b/design/src/main/java/com/urlaunched/android/design/ui/imagePicker/model/ImagePickerError.kt new file mode 100644 index 00000000..439ce50c --- /dev/null +++ b/design/src/main/java/com/urlaunched/android/design/ui/imagePicker/model/ImagePickerError.kt @@ -0,0 +1,6 @@ +package com.urlaunched.android.design.ui.imagepicker.model + +enum class ImagePickerError { + PHOTO_TOO_LARGE, + COMPRESSION_FAILED +} \ No newline at end of file diff --git a/design/src/main/java/com/urlaunched/android/design/ui/imagePicker/model/ImagePickerTextStyles.kt b/design/src/main/java/com/urlaunched/android/design/ui/imagePicker/model/ImagePickerTextStyles.kt new file mode 100644 index 00000000..e99f41e7 --- /dev/null +++ b/design/src/main/java/com/urlaunched/android/design/ui/imagePicker/model/ImagePickerTextStyles.kt @@ -0,0 +1,9 @@ +package com.urlaunched.android.design.ui.imagepicker.model + +import androidx.compose.ui.text.TextStyle + +data class ImagePickerTextStyles( + val headlineStyle: TextStyle = TextStyle(), + val optionTextStyle: TextStyle = TextStyle(), + val dismissButtonStyle: TextStyle = TextStyle() +) \ No newline at end of file diff --git a/design/src/main/java/com/urlaunched/android/design/ui/imagePicker/model/ImageSource.kt b/design/src/main/java/com/urlaunched/android/design/ui/imagePicker/model/ImageSource.kt new file mode 100644 index 00000000..91867e5f --- /dev/null +++ b/design/src/main/java/com/urlaunched/android/design/ui/imagePicker/model/ImageSource.kt @@ -0,0 +1,29 @@ +package com.urlaunched.android.design.ui.imagepicker.model + +import androidx.annotation.DrawableRes +import androidx.compose.ui.graphics.Color +import com.urlaunched.android.design.R + +sealed class ImageSource { + @get:DrawableRes + abstract val iconResId: Int + abstract val title: String + abstract val contentDescription: String? + abstract val iconTint: Color + + data class Camera( + override val title: String, + @DrawableRes + override val iconResId: Int = R.drawable.ic_camera, + override val contentDescription: String? = title, + override val iconTint: Color = Color.Unspecified + ) : ImageSource() + + data class Gallery( + override val title: String, + @DrawableRes + override val iconResId: Int = R.drawable.ic_gallery, + override val contentDescription: String? = title, + override val iconTint: Color = Color.Unspecified + ) : ImageSource() +} \ No newline at end of file diff --git a/design/src/main/java/com/urlaunched/android/design/ui/imagePicker/model/ImagesCount.kt b/design/src/main/java/com/urlaunched/android/design/ui/imagePicker/model/ImagesCount.kt new file mode 100644 index 00000000..4647fc75 --- /dev/null +++ b/design/src/main/java/com/urlaunched/android/design/ui/imagePicker/model/ImagesCount.kt @@ -0,0 +1,20 @@ +package com.urlaunched.android.design.ui.imagepicker.model + +import androidx.annotation.IntRange + +@JvmInline +value class ImagesCount( + @IntRange(from = 1) + val count: Int +) : Comparable { + + override fun compareTo(other: ImagesCount): Int { + return this.count.compareTo(other.count) + } + + companion object { + val SINGLE_IMAGE = ImagesCount(1) + val MULTIPLES_IMAGES_DEFAULT = ImagesCount(6) + val UNLIMITED = ImagesCount(Int.MAX_VALUE) + } +} \ No newline at end of file diff --git a/design/src/main/res/drawable/ic_camera.xml b/design/src/main/res/drawable/ic_camera.xml new file mode 100644 index 00000000..12d78aa6 --- /dev/null +++ b/design/src/main/res/drawable/ic_camera.xml @@ -0,0 +1,17 @@ + + + + + diff --git a/design/src/main/res/drawable/ic_gallery.xml b/design/src/main/res/drawable/ic_gallery.xml new file mode 100644 index 00000000..80db52e5 --- /dev/null +++ b/design/src/main/res/drawable/ic_gallery.xml @@ -0,0 +1,17 @@ + + + + +