Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions common/build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -106,6 +106,7 @@ dependencies {

// Accompanist
implementation libs.composeAccompanist.navigation
implementation libs.composeAccompanist.permissions

// Kotlin reflect
implementation libs.kotlin.reflect
Expand Down
7 changes: 7 additions & 0 deletions design/build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -69,9 +69,11 @@ 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
implementation libs.composeDependencies.runtime
debugImplementation libs.composeDependencies.composeUiTooling
debugImplementation libs.composeDependencies.composeUiTestManifest

Expand All @@ -91,7 +93,12 @@ dependencies {

// Local modules
implementation project(":cdn:models:presentation")
implementation project(":common")
implementation project(":tempattachment")

// Bottom sheet
implementation libs.bottomSheet

implementation libs.composeAccompanist.permissions

}
Original file line number Diff line number Diff line change
@@ -0,0 +1,209 @@
package com.urlaunched.android.design.ui.imagepicker

import android.Manifest
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.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.files.FileHelper
import com.urlaunched.android.common.files.TakeCameraPictureContract
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)
@Composable
fun ImagePicker(
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 cameraPickerContract = remember {
TakeCameraPictureContract(fileProviderAuthority = context.packageName + FILE_PROVIDER)
}

val cameraLauncher = rememberLauncherForActivityResult(
contract = cameraPickerContract,
onResult = { file ->
if (file != null) {
state.processPickedFiles(files = listOf(file), context = context)
}
}
)

val pickerLauncher = rememberLauncherForActivityResult(
contract = ActivityResultContracts.PickVisualMedia(),
onResult = { uri ->
if (uri != null) {
state.processPickedUris(uris = listOf(uri), context = context)
}
}
)

val multiplePhotoPickerLauncher = if (state.maxImagesCount > ImagesCount.SINGLE_IMAGE) {
rememberLauncherForActivityResult(
contract = ActivityResultContracts.PickMultipleVisualMedia(maxItems = state.maxImagesCount.count),
onResult = { uris ->
state.processPickedUris(uris = uris, context = context)
}
)
} else {
null
}

val cameraPermissionState = rememberPermissionState(permission = Manifest.permission.CAMERA)

val cameraRequestPermissionLauncher = rememberLauncherForActivityResult(
contract = ActivityResultContracts.RequestPermission(),
onResult = { granted ->
if (granted) {
launchCameraPicker(cameraLauncher)
}
}
)

if (state.deleteTempFilesOnDispose) {
DisposableEffect(Unit) {
onDispose {
FileHelper.deleteTempFilesFromCache(context)
}
}
}

if (state.isSourceSelectorDialogShown) {
ImageSourceSelectorDialog(
onGalleryClick = {
if (state.maxImagesCount > ImagesCount.SINGLE_IMAGE && multiplePhotoPickerLauncher != null) {
launchMultipleImagePicker(multiplePhotoPickerLauncher)
} else {
launchImagePicker(pickerLauncher)
}
},
onCameraClick = {
if (!cameraPermissionState.status.isGranted) {
cameraRequestPermissionLauncher.launch(Manifest.permission.CAMERA)
} else {
launchCameraPicker(cameraLauncher)
}
},
onDismiss = state::hideSourceSelectorDialog,
cameraSource = cameraSource,
gallerySource = gallerySource,
headlineText = headlineText,
dismissButtonText = dismissButtonText,
style = style,
textStyles = textStyles
)
}
}
}

@Preview(showBackground = true)
@Composable
private fun ImagePickerPreview() {
var pickedImage by remember { mutableStateOf<List<File>>(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))
)
}
}

Button(
enabled = pickedImage.size < 3,
onClick = {
imagePickerState.showSourceSelectorDialog(ImagesCount(count = 3 - pickedImage.size))
}
) {
Text("Pick images")
}
}
}

private fun launchImagePicker(pickerLauncher: ManagedActivityResultLauncher<PickVisualMediaRequest, Uri?>) {
pickerLauncher.launch(
PickVisualMediaRequest(
mediaType = ActivityResultContracts.PickVisualMedia.ImageOnly
)
)
}

private fun launchCameraPicker(pickerLauncher: ManagedActivityResultLauncher<Unit, File?>) {
pickerLauncher.launch(Unit)
}

private fun launchMultipleImagePicker(
pickerLauncher: ManagedActivityResultLauncher<PickVisualMediaRequest, List<Uri>>
) {
pickerLauncher.launch(
PickVisualMediaRequest(
mediaType = ActivityResultContracts.PickVisualMedia.ImageOnly
)
)
}
Original file line number Diff line number Diff line change
@@ -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(
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Давай ще додамо конфіг для того, щоб не відображати діалог з вибором камера чи файл, щоб можна було одразу відображати тільки файл-пікер. Я думаю, це може бути корисно в певних випадках

val compressionConfig: CompressionConfig = CompressionConfig(),
val coroutineScope: CoroutineScope,
val deleteTempFilesOnDispose: Boolean,
val onError: suspend (error: ImagePickerError) -> Unit,
val onImagesPicked: (images: List<File>) -> 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<Uri>, context: Context) {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Мені здається, що цей метод має бути приватним

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<File>, context: Context) {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

і цей

coroutineScope.launch(Dispatchers.IO) {
isImageCompressionProceed = true
val validFiles = mutableListOf<File>()

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,
Comment on lines +104 to +105
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Я не бачу сенсу створювати ще один overload з параметрами, які фактично не використовуються в стейті, а просто повертаються в тому ж колбеці showErrorSnackbar

compressionConfig: CompressionConfig = CompressionConfig(),
coroutineScope: CoroutineScope = rememberCoroutineScope(),
deleteTempFilesOnDispose: Boolean = true,
showErrorSnackbar: suspend (message: String) -> Unit,
onImagesPicked: (images: List<File>) -> 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<File>) -> Unit
) = remember {
ImagePickerState(
compressionConfig = compressionConfig,
coroutineScope = coroutineScope,
onError = onError,
deleteTempFilesOnDispose = deleteTempFilesOnDispose,
onImagesPicked = onImagesPicked
)
}
Loading