diff --git a/app/build.gradle.kts b/app/build.gradle.kts index dafea727..59e2ebe0 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -88,7 +88,6 @@ android { dependencies { implementation(libs.purchases) implementation(libs.purchases.ui) - implementation(libs.easycrop) implementation(libs.accompanist.permissions) implementation(platform(libs.firebase.bom)) implementation(libs.firebase.analytics) @@ -103,6 +102,7 @@ dependencies { implementation(libs.play.services.ads) implementation(projects.composeCardstack) + implementation(projects.easycrop) implementation(libs.lottie.compose) // Compose diff --git a/app/src/main/java/com/pwhs/quickmem/presentation/app/flashcard/component/ExplanationCard.kt b/app/src/main/java/com/pwhs/quickmem/presentation/app/flashcard/component/ExplanationCard.kt new file mode 100644 index 00000000..3688d749 --- /dev/null +++ b/app/src/main/java/com/pwhs/quickmem/presentation/app/flashcard/component/ExplanationCard.kt @@ -0,0 +1,68 @@ +package com.pwhs.quickmem.presentation.app.flashcard.component + +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Clear +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme.colorScheme +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import com.pwhs.quickmem.R + +@Composable +fun ExplanationCard( + modifier: Modifier = Modifier, + explanation: String, + onExplanationChanged: (String) -> Unit, + onShowExplanationClicked: (Boolean) -> Unit +) { + Card( + modifier = modifier + .fillMaxWidth() + .padding(16.dp), + elevation = CardDefaults.elevatedCardElevation( + defaultElevation = 5.dp, + focusedElevation = 8.dp + ), + colors = CardDefaults.cardColors( + containerColor = colorScheme.surface + ), + ) { + Box( + contentAlignment = Alignment.Center + ) { + Column( + modifier = Modifier.padding(16.dp) + ) { + FlashCardTextField( + value = explanation, + onValueChange = onExplanationChanged, + hint = stringResource(R.string.txt_explanation) + ) + } + + IconButton( + onClick = { + onShowExplanationClicked(false) + onExplanationChanged("") + }, + modifier = Modifier.align(Alignment.TopEnd) + ) { + Icon( + imageVector = Icons.Filled.Clear, + contentDescription = stringResource(R.string.txt_close), + ) + } + } + } + +} \ No newline at end of file diff --git a/app/src/main/java/com/pwhs/quickmem/presentation/app/flashcard/component/HintCard.kt b/app/src/main/java/com/pwhs/quickmem/presentation/app/flashcard/component/HintCard.kt new file mode 100644 index 00000000..0d76e0a5 --- /dev/null +++ b/app/src/main/java/com/pwhs/quickmem/presentation/app/flashcard/component/HintCard.kt @@ -0,0 +1,67 @@ +package com.pwhs.quickmem.presentation.app.flashcard.component + +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Clear +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme.colorScheme +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import com.pwhs.quickmem.R + +@Composable +fun HintCard( + modifier: Modifier = Modifier, + hint: String, + onHintChanged: (String) -> Unit, + onShowHintClicked: (Boolean) -> Unit +) { + Card( + modifier = modifier + .fillMaxWidth() + .padding(16.dp), + elevation = CardDefaults.elevatedCardElevation( + defaultElevation = 5.dp, + focusedElevation = 8.dp + ), + colors = CardDefaults.cardColors( + containerColor = colorScheme.surface + ), + ) { + Box( + contentAlignment = Alignment.Center + ) { + Column( + modifier = Modifier.padding(16.dp) + ) { + FlashCardTextField( + value = hint, + onValueChange = onHintChanged, + hint = stringResource(R.string.txt_hint) + ) + } + + IconButton( + onClick = { + onShowHintClicked(false) + onHintChanged("") + }, + modifier = Modifier.align(Alignment.TopEnd) + ) { + Icon( + imageVector = Icons.Filled.Clear, + contentDescription = stringResource(R.string.txt_close), + ) + } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/pwhs/quickmem/presentation/app/flashcard/create/CreateFlashCardScreen.kt b/app/src/main/java/com/pwhs/quickmem/presentation/app/flashcard/create/CreateFlashCardScreen.kt index 17e51ad5..d68e2fc5 100644 --- a/app/src/main/java/com/pwhs/quickmem/presentation/app/flashcard/create/CreateFlashCardScreen.kt +++ b/app/src/main/java/com/pwhs/quickmem/presentation/app/flashcard/create/CreateFlashCardScreen.kt @@ -3,20 +3,12 @@ package com.pwhs.quickmem.presentation.app.flashcard.create import android.net.Uri import android.widget.Toast import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.lazy.LazyColumn -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.Clear -import androidx.compose.material3.Card -import androidx.compose.material3.CardDefaults import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.HorizontalDivider -import androidx.compose.material3.Icon -import androidx.compose.material3.IconButton -import androidx.compose.material3.MaterialTheme.colorScheme import androidx.compose.material3.Scaffold import androidx.compose.material3.Text import androidx.compose.material3.rememberModalBottomSheetState @@ -37,7 +29,9 @@ import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.tooling.preview.PreviewLightDark import androidx.compose.ui.unit.dp import androidx.hilt.navigation.compose.hiltViewModel -import com.mr0xf00.easycrop.CropperStyle +import com.mr0xf00.easycrop.CropError +import com.mr0xf00.easycrop.CropResult +import com.mr0xf00.easycrop.crop import com.mr0xf00.easycrop.rememberImageCropper import com.mr0xf00.easycrop.rememberImagePicker import com.mr0xf00.easycrop.ui.ImageCropperDialog @@ -45,14 +39,16 @@ import com.pwhs.quickmem.R import com.pwhs.quickmem.domain.model.pixabay.SearchImageResponseModel import com.pwhs.quickmem.presentation.ads.BannerAds import com.pwhs.quickmem.presentation.app.flashcard.component.CardSelectImage -import com.pwhs.quickmem.presentation.app.flashcard.component.FlashCardTextField +import com.pwhs.quickmem.presentation.app.flashcard.component.ExplanationCard import com.pwhs.quickmem.presentation.app.flashcard.component.FlashCardTextFieldContainer import com.pwhs.quickmem.presentation.app.flashcard.component.FlashCardTopAppBar import com.pwhs.quickmem.presentation.app.flashcard.component.FlashcardBottomSheet import com.pwhs.quickmem.presentation.app.flashcard.component.FlashcardSelectImageBottomSheet +import com.pwhs.quickmem.presentation.app.flashcard.component.HintCard import com.pwhs.quickmem.presentation.component.LoadingOverlay import com.pwhs.quickmem.ui.theme.QuickMemTheme import com.pwhs.quickmem.util.ImageCompressor +import com.pwhs.quickmem.util.bitmapToUri import com.ramcosta.composedestinations.annotation.Destination import com.ramcosta.composedestinations.annotation.RootGraph import com.ramcosta.composedestinations.navigation.DestinationsNavigator @@ -72,7 +68,9 @@ fun CreateFlashCardScreen( resultNavigator: ResultBackNavigator, ) { val uiState by viewModel.uiState.collectAsState() + val scope = rememberCoroutineScope() val context = LocalContext.current + val imageCompressor = remember { ImageCompressor(context) } LaunchedEffect(key1 = true) { viewModel.uiEvent.collect { event -> when (event) { @@ -115,12 +113,28 @@ fun CreateFlashCardScreen( ) ) }, - onDefinitionImageChanged = { - viewModel.onEvent( - CreateFlashCardUiAction.FlashCardDefinitionImageChanged( - it + onDefinitionImageChanged = { uri -> + if (uri == null) { + viewModel.onEvent(CreateFlashCardUiAction.FlashCardDefinitionImageChanged(null)) + return@CreateFlashCard + } + scope.launch { + val compressedImageBytes = imageCompressor.compressImage(uri, 200 * 1024L) // 200KB + val compressedImageUri = compressedImageBytes?.let { + Uri.fromFile( + File( + context.cacheDir, + "compressed_image_${System.currentTimeMillis()}.jpg" + ).apply { + writeBytes(it) + }) + } + viewModel.onEvent( + CreateFlashCardUiAction.FlashCardDefinitionImageChanged( + compressedImageUri + ) ) - ) + } }, onHintChanged = { viewModel.onEvent(CreateFlashCardUiAction.FlashCardHintChanged(it)) }, onShowHintClicked = { viewModel.onEvent(CreateFlashCardUiAction.ShowHintClicked(it)) }, @@ -204,29 +218,23 @@ fun CreateFlashCard( val imageCropper = rememberImageCropper() val scope = rememberCoroutineScope() val context = LocalContext.current - val imageCompressor = remember { ImageCompressor(context) } val imagePicker = rememberImagePicker(onImage = { uri -> scope.launch { - val compressedImageBytes = imageCompressor.compressImage(uri, 200 * 1024L) // 200KB - val compressedImageUri = compressedImageBytes?.let { - Uri.fromFile(File(context.cacheDir, "compressed_image.jpg").apply { - writeBytes(it) - }) + when (val result = imageCropper.crop(uri, context)) { + CropResult.Cancelled -> { /* Handle cancellation */ + } + + is CropError -> { /* Handle error */ + } + + is CropResult.Success -> { + onDefinitionImageChanged(context.bitmapToUri(result.bitmap)) + } } - onDefinitionImageChanged(compressedImageUri) } }) val cropState = imageCropper.cropState - if (cropState != null) { - ImageCropperDialog( - state = cropState, style = CropperStyle( - backgroundColor = Color.Black.copy(alpha = 0.8f), - rectColor = Color.White, - overlay = Color.Black.copy(alpha = 0.5f), - ) - ) - } var showSearchImageBottomSheet by remember { mutableStateOf(false) @@ -234,197 +242,136 @@ fun CreateFlashCard( val searchImageBottomSheet = rememberModalBottomSheetState() - - Scaffold( - topBar = { - FlashCardTopAppBar( - onNavigationBack = onNavigationBack, - onSaveFlashCardClicked = onSaveFlashCardClicked, - enableSaveButton = term.isNotEmpty() && definition.isNotEmpty(), - onSettingsClicked = { - showBottomSheetSetting = true - }, - title = stringResource(R.string.txt_create_flashcard) - ) - }, - modifier = modifier - .fillMaxSize() - ) { innerPadding -> - Box(contentAlignment = Alignment.TopCenter) { - LazyColumn( - modifier = modifier - .padding(innerPadding) - .fillMaxSize() - ) { - - item { - CardSelectImage( - modifier = Modifier - .fillMaxWidth() - .padding(16.dp), - onUploadImage = onUploadImage, - definitionImageUri = definitionImageUri, - definitionImageUrl = definitionImageURL, - onDeleteImage = onDeleteImage, - onChooseImage = { - showSearchImageBottomSheet = true - } - ) - } - item { - FlashCardTextFieldContainer( - term = term, - onTermChanged = onTermChanged, - definition = definition, - onDefinitionChanged = onDefinitionChanged - ) - } - - item { - if (showHint) { - Card( + Box( + modifier = modifier.fillMaxSize() + ) { + Scaffold( + topBar = { + FlashCardTopAppBar( + onNavigationBack = onNavigationBack, + onSaveFlashCardClicked = onSaveFlashCardClicked, + enableSaveButton = term.isNotEmpty() && definition.isNotEmpty(), + onSettingsClicked = { + showBottomSheetSetting = true + }, + title = stringResource(R.string.txt_create_flashcard) + ) + }, + modifier = modifier + .fillMaxSize() + ) { innerPadding -> + Box(contentAlignment = Alignment.TopCenter) { + LazyColumn( + modifier = modifier + .padding(innerPadding) + .fillMaxSize() + ) { + item { + CardSelectImage( modifier = Modifier .fillMaxWidth() .padding(16.dp), - elevation = CardDefaults.elevatedCardElevation( - defaultElevation = 5.dp, - focusedElevation = 8.dp - ), - colors = CardDefaults.cardColors( - containerColor = colorScheme.surface - ), - ) { - Box( - contentAlignment = Alignment.Center - ) { - Column( - modifier = Modifier.padding(16.dp) - ) { - FlashCardTextField( - value = hint, - onValueChange = onHintChanged, - hint = stringResource(R.string.txt_hint) - ) - } - - IconButton( - onClick = { - onShowHintClicked(false) - onHintChanged("") - }, - modifier = Modifier.align(Alignment.TopEnd) - ) { - Icon( - imageVector = Icons.Filled.Clear, - contentDescription = stringResource(R.string.txt_close), - ) - } + onUploadImage = onUploadImage, + definitionImageUri = definitionImageUri, + definitionImageUrl = definitionImageURL, + onDeleteImage = onDeleteImage, + onChooseImage = { + showSearchImageBottomSheet = true } + ) + } + item { + FlashCardTextFieldContainer( + term = term, + onTermChanged = onTermChanged, + definition = definition, + onDefinitionChanged = onDefinitionChanged + ) + } + + item { + if (showHint) { + HintCard( + hint = hint, + onHintChanged = onHintChanged, + onShowHintClicked = onShowHintClicked + ) + } + } + + item { + if (showExplanation) { + ExplanationCard( + explanation = explanation, + onExplanationChanged = onExplanationChanged, + onShowExplanationClicked = onShowExplanationClicked + ) } } - } - item { - if (showExplanation) { - Card( + item { + HorizontalDivider( modifier = Modifier .fillMaxWidth() - .padding(16.dp), - elevation = CardDefaults.elevatedCardElevation( - defaultElevation = 5.dp, - focusedElevation = 8.dp - ), - colors = CardDefaults.cardColors( - containerColor = colorScheme.surface - ), - ) { - Box( - contentAlignment = Alignment.Center - ) { - Column( - modifier = Modifier.padding(16.dp) - ) { - FlashCardTextField( - value = explanation, - onValueChange = onExplanationChanged, - hint = stringResource(R.string.txt_explanation) - ) - } - - IconButton( - onClick = { - onShowExplanationClicked(false) - onExplanationChanged("") - }, - modifier = Modifier.align(Alignment.TopEnd) - ) { - Icon( - imageVector = Icons.Filled.Clear, - contentDescription = stringResource(R.string.txt_close), - ) - } - } - } + .padding(16.dp) + ) } - } - item { - HorizontalDivider( - modifier = Modifier - .fillMaxWidth() - .padding(16.dp) - ) - } + item { + Text( + text = stringResource(R.string.txt_make_your_term_and_definition_as_clear_as_possible_you_can_add_hint_and_explanation_to_help_you_remember_better), + modifier = Modifier + .fillMaxWidth() + .padding(16.dp) + .padding(bottom = 32.dp), + color = Color.Gray, + textAlign = TextAlign.Center + ) + } - item { - Text( - text = stringResource(R.string.txt_make_your_term_and_definition_as_clear_as_possible_you_can_add_hint_and_explanation_to_help_you_remember_better), - modifier = Modifier - .fillMaxWidth() - .padding(16.dp) - .padding(bottom = 32.dp), - color = Color.Gray, - textAlign = TextAlign.Center - ) } + BannerAds( + modifier = Modifier + .fillMaxWidth() + .align(Alignment.BottomCenter) + .padding(16.dp) + ) + LoadingOverlay(isLoading = isLoading) + } + if (showBottomSheetSetting) { + FlashcardBottomSheet( + onDismissRequest = { + showBottomSheetSetting = false + }, + sheetState = bottomSheetSetting, + onShowHintClicked = onShowHintClicked, + onShowExplanationClicked = onShowExplanationClicked + ) } - BannerAds( - modifier = Modifier - .fillMaxWidth() - .align(Alignment.BottomCenter) - .padding(16.dp) - ) - LoadingOverlay(isLoading = isLoading) - } - if (showBottomSheetSetting) { - FlashcardBottomSheet( - onDismissRequest = { - showBottomSheetSetting = false - }, - sheetState = bottomSheetSetting, - onShowHintClicked = onShowHintClicked, - onShowExplanationClicked = onShowExplanationClicked - ) + if (showSearchImageBottomSheet) { + FlashcardSelectImageBottomSheet( + modifier = Modifier, + searchImageBottomSheet = searchImageBottomSheet, + onDismissRequest = { + showSearchImageBottomSheet = false + }, + queryImage = queryImage, + searchImageResponseModel = searchImageResponseModel, + onQueryImageChanged = onQueryImageChanged, + isSearchImageLoading = isSearchImageLoading, + onDefinitionImageUrlChanged = { + onDefinitionImageUrlChanged(it) + onDefinitionImageChanged(null) + }, + imagePicker = imagePicker + ) + } } - - if (showSearchImageBottomSheet) { - FlashcardSelectImageBottomSheet( - modifier = Modifier, - searchImageBottomSheet = searchImageBottomSheet, - onDismissRequest = { - showSearchImageBottomSheet = false - }, - queryImage = queryImage, - searchImageResponseModel = searchImageResponseModel, - onQueryImageChanged = onQueryImageChanged, - isSearchImageLoading = isSearchImageLoading, - onDefinitionImageUrlChanged = { - onDefinitionImageUrlChanged(it) - onDefinitionImageChanged(null) - }, - imagePicker = imagePicker + if (cropState != null) { + ImageCropperDialog( + state = cropState, ) } } diff --git a/app/src/main/java/com/pwhs/quickmem/presentation/app/flashcard/edit/EditFlashCardScreen.kt b/app/src/main/java/com/pwhs/quickmem/presentation/app/flashcard/edit/EditFlashCardScreen.kt index 71cf5c39..a99b9535 100644 --- a/app/src/main/java/com/pwhs/quickmem/presentation/app/flashcard/edit/EditFlashCardScreen.kt +++ b/app/src/main/java/com/pwhs/quickmem/presentation/app/flashcard/edit/EditFlashCardScreen.kt @@ -3,20 +3,12 @@ package com.pwhs.quickmem.presentation.app.flashcard.edit import android.net.Uri import android.widget.Toast import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.lazy.LazyColumn -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.Clear -import androidx.compose.material3.Card -import androidx.compose.material3.CardDefaults import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.HorizontalDivider -import androidx.compose.material3.Icon -import androidx.compose.material3.IconButton -import androidx.compose.material3.MaterialTheme.colorScheme import androidx.compose.material3.Scaffold import androidx.compose.material3.Text import androidx.compose.material3.rememberModalBottomSheetState @@ -34,12 +26,11 @@ import androidx.compose.ui.graphics.Color import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.style.TextAlign -import androidx.compose.ui.tooling.preview.PreviewLightDark +import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import androidx.hilt.navigation.compose.hiltViewModel import com.mr0xf00.easycrop.CropError import com.mr0xf00.easycrop.CropResult -import com.mr0xf00.easycrop.CropperStyle import com.mr0xf00.easycrop.crop import com.mr0xf00.easycrop.rememberImageCropper import com.mr0xf00.easycrop.rememberImagePicker @@ -48,11 +39,12 @@ import com.pwhs.quickmem.R import com.pwhs.quickmem.domain.model.pixabay.SearchImageResponseModel import com.pwhs.quickmem.presentation.ads.BannerAds import com.pwhs.quickmem.presentation.app.flashcard.component.CardSelectImage -import com.pwhs.quickmem.presentation.app.flashcard.component.FlashCardTextField +import com.pwhs.quickmem.presentation.app.flashcard.component.ExplanationCard import com.pwhs.quickmem.presentation.app.flashcard.component.FlashCardTextFieldContainer import com.pwhs.quickmem.presentation.app.flashcard.component.FlashCardTopAppBar import com.pwhs.quickmem.presentation.app.flashcard.component.FlashcardBottomSheet import com.pwhs.quickmem.presentation.app.flashcard.component.FlashcardSelectImageBottomSheet +import com.pwhs.quickmem.presentation.app.flashcard.component.HintCard import com.pwhs.quickmem.presentation.component.LoadingOverlay import com.pwhs.quickmem.ui.theme.QuickMemTheme import com.pwhs.quickmem.util.ImageCompressor @@ -131,9 +123,13 @@ fun EditFlashCardScreen( scope.launch { val compressedImageBytes = imageCompressor.compressImage(uri, 200 * 1024L) // 200KB val compressedImageUri = compressedImageBytes?.let { - Uri.fromFile(File(context.cacheDir, "compressed_image.jpg").apply { - writeBytes(it) - }) + Uri.fromFile( + File( + context.cacheDir, + "compressed_image_${System.currentTimeMillis()}.jpg" + ).apply { + writeBytes(it) + }) } viewModel.onEvent( EditFlashCardUiAction.FlashCardDefinitionImageChanged( @@ -242,15 +238,6 @@ fun CreateFlashCard( }) val cropState = imageCropper.cropState - if (cropState != null) { - ImageCropperDialog( - state = cropState, style = CropperStyle( - backgroundColor = Color.Black.copy(alpha = 0.8f), - rectColor = Color.White, - overlay = Color.Black.copy(alpha = 0.5f), - ) - ) - } var showSearchImageBottomSheet by remember { mutableStateOf(false) @@ -259,202 +246,144 @@ fun CreateFlashCard( val searchImageBottomSheet = rememberModalBottomSheetState() - Scaffold( - topBar = { - FlashCardTopAppBar( - onNavigationBack = onNavigationBack, - onSaveFlashCardClicked = onSaveFlashCardClicked, - enableSaveButton = term.isNotEmpty() && definition.isNotEmpty(), - onSettingsClicked = { - showBottomSheetSetting = true - }, - title = stringResource(R.string.txt_edit_flashcard) - ) - }, - modifier = modifier - .fillMaxSize() - ) { innerPadding -> - Box(contentAlignment = Alignment.TopCenter) { - LazyColumn( - modifier = modifier - .padding(innerPadding) - .fillMaxSize() - ) { - - item { - CardSelectImage( - modifier = Modifier - .fillMaxWidth() - .padding(16.dp), - onUploadImage = onUploadImage, - definitionImageUri = definitionImageUri, - definitionImageUrl = definitionImageURL, - onDeleteImage = onDeleteImage, - onChooseImage = { - showSearchImageBottomSheet = true - } - ) - } - item { - FlashCardTextFieldContainer( - term = term, - onTermChanged = onTermChanged, - definition = definition, - onDefinitionChanged = onDefinitionChanged - ) - } + Box( + modifier = modifier.fillMaxSize() + ) { + Scaffold( + topBar = { + FlashCardTopAppBar( + onNavigationBack = onNavigationBack, + onSaveFlashCardClicked = onSaveFlashCardClicked, + enableSaveButton = term.isNotEmpty() && definition.isNotEmpty(), + onSettingsClicked = { + showBottomSheetSetting = true + }, + title = stringResource(R.string.txt_edit_flashcard) + ) + }, + modifier = modifier + .fillMaxSize() + ) { innerPadding -> + Box(contentAlignment = Alignment.TopCenter) { + LazyColumn( + modifier = modifier + .padding(innerPadding) + .fillMaxSize() + ) { - item { - if (showHint || hint.isNotEmpty()) { - Card( + item { + CardSelectImage( modifier = Modifier .fillMaxWidth() .padding(16.dp), - elevation = CardDefaults.elevatedCardElevation( - defaultElevation = 5.dp, - focusedElevation = 8.dp - ), - colors = CardDefaults.cardColors( - containerColor = colorScheme.surface - ), - ) { - Box( - contentAlignment = Alignment.Center - ) { - Column( - modifier = Modifier.padding(16.dp) - ) { - FlashCardTextField( - value = hint, - onValueChange = onHintChanged, - hint = stringResource(R.string.txt_hint) - ) - } - - IconButton( - onClick = { - onShowHintClicked(false) - onHintChanged("") - }, - modifier = Modifier.align(Alignment.TopEnd) - ) { - Icon( - imageVector = Icons.Filled.Clear, - contentDescription = "Close", - ) - } + onUploadImage = onUploadImage, + definitionImageUri = definitionImageUri, + definitionImageUrl = definitionImageURL, + onDeleteImage = onDeleteImage, + onChooseImage = { + showSearchImageBottomSheet = true } + ) + } + item { + FlashCardTextFieldContainer( + term = term, + onTermChanged = onTermChanged, + definition = definition, + onDefinitionChanged = onDefinitionChanged + ) + } + + item { + if (showHint || hint.isNotEmpty()) { + HintCard( + hint = hint, + onHintChanged = onHintChanged, + onShowHintClicked = onShowHintClicked + ) } } - } - item { - if (showExplanation || explanation.isNotEmpty()) { - Card( + item { + if (showExplanation || explanation.isNotEmpty()) { + ExplanationCard( + explanation = explanation, + onExplanationChanged = onExplanationChanged, + onShowExplanationClicked = onShowExplanationClicked + ) + } + } + + item { + HorizontalDivider( modifier = Modifier .fillMaxWidth() - .padding(16.dp), - elevation = CardDefaults.elevatedCardElevation( - defaultElevation = 5.dp, - focusedElevation = 8.dp - ), - colors = CardDefaults.cardColors( - containerColor = colorScheme.surface - ), - ) { - Box( - contentAlignment = Alignment.Center - ) { - Column( - modifier = Modifier.padding(16.dp) - ) { - FlashCardTextField( - value = explanation, - onValueChange = onExplanationChanged, - hint = stringResource(R.string.txt_explanation) - ) - } - - IconButton( - onClick = { - onShowExplanationClicked(false) - onExplanationChanged("") - }, - modifier = Modifier.align(Alignment.TopEnd) - ) { - Icon( - imageVector = Icons.Filled.Clear, - contentDescription = "Close", - ) - } - } - } + .padding(16.dp) + ) } - } - item { - HorizontalDivider( - modifier = Modifier - .fillMaxWidth() - .padding(16.dp) - ) - } + item { + Text( + text = stringResource(R.string.txt_make_your_term_and_definition_as_clear_as_possible_you_can_add_hint_and_explanation_to_help_you_remember_better), + modifier = Modifier + .fillMaxWidth() + .padding(16.dp) + .padding(bottom = 32.dp), + color = Color.Gray, + textAlign = TextAlign.Center + ) + } - item { - Text( - text = stringResource(R.string.txt_make_your_term_and_definition_as_clear_as_possible_you_can_add_hint_and_explanation_to_help_you_remember_better), - modifier = Modifier - .fillMaxWidth() - .padding(16.dp) - .padding(bottom = 32.dp), - color = Color.Gray, - textAlign = TextAlign.Center - ) } - + BannerAds( + modifier = Modifier + .fillMaxWidth() + .align(Alignment.BottomCenter) + .padding(16.dp) + ) + LoadingOverlay(isLoading = isLoading) } - BannerAds( - modifier = Modifier - .fillMaxWidth() - .align(Alignment.BottomCenter) - .padding(16.dp) - ) - LoadingOverlay(isLoading = isLoading) - } - if (showBottomSheetSetting) { - FlashcardBottomSheet( - onDismissRequest = { - showBottomSheetSetting = false - }, - sheetState = bottomSheetSetting, - onShowHintClicked = onShowHintClicked, - onShowExplanationClicked = onShowExplanationClicked - ) + if (showBottomSheetSetting) { + FlashcardBottomSheet( + onDismissRequest = { + showBottomSheetSetting = false + }, + sheetState = bottomSheetSetting, + onShowHintClicked = onShowHintClicked, + onShowExplanationClicked = onShowExplanationClicked + ) + } + if (showSearchImageBottomSheet) { + FlashcardSelectImageBottomSheet( + modifier = Modifier, + searchImageBottomSheet = searchImageBottomSheet, + onDismissRequest = { + showSearchImageBottomSheet = false + }, + queryImage = queryImage, + searchImageResponseModel = searchImageResponseModel, + onQueryImageChanged = onQueryImageChanged, + isSearchImageLoading = isSearchImageLoading, + onDefinitionImageUrlChanged = { + onDefinitionImageUrlChanged(it) + onDefinitionImageChanged(null) + }, + imagePicker = imagePicker + ) + } } - if (showSearchImageBottomSheet) { - FlashcardSelectImageBottomSheet( - modifier = Modifier, - searchImageBottomSheet = searchImageBottomSheet, - onDismissRequest = { - showSearchImageBottomSheet = false - }, - queryImage = queryImage, - searchImageResponseModel = searchImageResponseModel, - onQueryImageChanged = onQueryImageChanged, - isSearchImageLoading = isSearchImageLoading, - onDefinitionImageUrlChanged = { - onDefinitionImageUrlChanged(it) - onDefinitionImageChanged(null) - }, - imagePicker = imagePicker + if (cropState != null) { + ImageCropperDialog( + state = cropState ) } } + } -@PreviewLightDark +@Preview(showBackground = true) @Composable fun CreateFlashCardPreview() { QuickMemTheme { diff --git a/easycrop/.gitignore b/easycrop/.gitignore new file mode 100644 index 00000000..42afabfd --- /dev/null +++ b/easycrop/.gitignore @@ -0,0 +1 @@ +/build \ No newline at end of file diff --git a/easycrop/build.gradle.kts b/easycrop/build.gradle.kts new file mode 100644 index 00000000..b118572b --- /dev/null +++ b/easycrop/build.gradle.kts @@ -0,0 +1,53 @@ +plugins { + alias(libs.plugins.android.library) + alias(libs.plugins.kotlin.android) + alias(libs.plugins.kotlin.compose) +} +android { + namespace = "com.mr0xf00.easycrop" + compileSdk = libs.versions.compileSdk.get().toInt() + + defaultConfig { + minSdk = libs.versions.minSdk.get().toInt() + } + + testOptions { + targetSdk = libs.versions.targetSdk.get().toInt() + } + + lint { + targetSdk = libs.versions.targetSdk.get().toInt() + } + + buildTypes { + release { + isMinifyEnabled = false + proguardFiles( + getDefaultProguardFile("proguard-android-optimize.txt"), + "proguard-rules.pro" + ) + } + } + compileOptions { + sourceCompatibility = JavaVersion.VERSION_17 + targetCompatibility = JavaVersion.VERSION_17 + } + kotlinOptions { + jvmTarget = libs.versions.jvmTarget.get() + } + buildFeatures { + compose = true + } +} + +dependencies { + implementation(platform(libs.androidx.compose.bom)) + implementation(libs.bundles.compose) + // Unit Test + testImplementation(libs.bundles.testing) + // Android Test + androidTestImplementation(libs.bundles.android.testing) + androidTestImplementation(platform(libs.androidx.compose.bom)) + // Debug Test + debugImplementation(libs.bundles.debugging) +} \ No newline at end of file diff --git a/easycrop/src/androidTest/java/com/mr0xf00/easycrop/ResultTest.kt b/easycrop/src/androidTest/java/com/mr0xf00/easycrop/ResultTest.kt new file mode 100644 index 00000000..a7686634 --- /dev/null +++ b/easycrop/src/androidTest/java/com/mr0xf00/easycrop/ResultTest.kt @@ -0,0 +1,93 @@ +package com.mr0xf00.easycrop + +import android.graphics.Bitmap +import android.graphics.BitmapFactory +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.geometry.Rect +import androidx.compose.ui.geometry.Size +import androidx.compose.ui.graphics.ImageBitmap +import androidx.compose.ui.graphics.asAndroidBitmap +import androidx.compose.ui.graphics.asImageBitmap +import androidx.compose.ui.graphics.toPixelMap +import androidx.compose.ui.unit.IntSize +import androidx.test.platform.app.InstrumentationRegistry +import com.mr0xf00.easycrop.images.ImageStream +import com.mr0xf00.easycrop.images.ImageStreamSrc +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.runTest +import org.junit.Assert +import org.junit.Test +import org.junit.Before + +@OptIn(ExperimentalCoroutinesApi::class) +class ResultTest { + + private lateinit var state: CropState + private val full = imageStream("dog.jpg") + + @Before + fun createState() = runTest { + val src = ImageStreamSrc(full) + checkNotNull(src) + state = CropState(src) + } + + @Test + fun image_is_unchanged_when_using_a_full_region_and_no_transform() = runTest { + val expected = full.openImage() + val actual = state.createResult(null) + assertEqual(expected, actual) + } + + @Test + fun correct_result_when_applying_transforms() = runTest { + state.rotLeft() + state.flipHorizontal() + val expected = imageStream("dog_rl_fh.png").openImage() + val actual = state.createResult(null) + assertEqual(expected, actual) + } + + @Test + fun correct_result_when_resizing_region_and_applying_transforms() = runTest { + state.rotLeft() + state.flipHorizontal() + state.region = Rect(Offset(294f, 86f), Size(182f, 143f)) + val expected = imageStream("dog_rl_fh_294_86_182_143.png").openImage() + val actual = state.createResult(null)?.apply { save() } + assertEqual(expected, actual) + } + + private fun imageStream(name: String): ImageStream { + return ImageStream { + javaClass.classLoader?.getResourceAsStream(name) + ?: throw IllegalStateException("ClassLoader not found") + } + } +} + +private fun ImageStream.openImage(): ImageBitmap { + return BitmapFactory.decodeStream(openStream(), null, null)?.asImageBitmap() + ?: error("Image $this cannot be opened") +} + +private fun assertEqual(expected: ImageBitmap, actual: ImageBitmap?) { + checkNotNull(actual) + Assert.assertEquals( + IntSize(expected.width, expected.height), + IntSize(actual.width, actual.height) + ) + Assert.assertArrayEquals( + expected.toPixelMap().buffer, + actual.toPixelMap().buffer + ) +} + +private fun ImageBitmap.save() { + val context = InstrumentationRegistry.getInstrumentation().targetContext + context.filesDir.resolve("result.png").outputStream().use { stream -> + asAndroidBitmap().compress( + Bitmap.CompressFormat.PNG, 100, stream + ) + } +} diff --git a/easycrop/src/androidTest/resources/dog.jpg b/easycrop/src/androidTest/resources/dog.jpg new file mode 100644 index 00000000..20882dd7 Binary files /dev/null and b/easycrop/src/androidTest/resources/dog.jpg differ diff --git a/easycrop/src/androidTest/resources/dog_rl_fh.png b/easycrop/src/androidTest/resources/dog_rl_fh.png new file mode 100644 index 00000000..d80db79b Binary files /dev/null and b/easycrop/src/androidTest/resources/dog_rl_fh.png differ diff --git a/easycrop/src/androidTest/resources/dog_rl_fh_294_86_182_143.png b/easycrop/src/androidTest/resources/dog_rl_fh_294_86_182_143.png new file mode 100644 index 00000000..8eae7987 Binary files /dev/null and b/easycrop/src/androidTest/resources/dog_rl_fh_294_86_182_143.png differ diff --git a/easycrop/src/main/AndroidManifest.xml b/easycrop/src/main/AndroidManifest.xml new file mode 100644 index 00000000..a5918e68 --- /dev/null +++ b/easycrop/src/main/AndroidManifest.xml @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/easycrop/src/main/java/com/mr0xf00/easycrop/CropShapes.kt b/easycrop/src/main/java/com/mr0xf00/easycrop/CropShapes.kt new file mode 100644 index 00000000..1bf68a76 --- /dev/null +++ b/easycrop/src/main/java/com/mr0xf00/easycrop/CropShapes.kt @@ -0,0 +1,69 @@ +package com.mr0xf00.easycrop + +import androidx.compose.runtime.Stable +import androidx.compose.ui.geometry.CornerRadius +import androidx.compose.ui.geometry.Rect +import androidx.compose.ui.geometry.RoundRect +import androidx.compose.ui.graphics.Path +import com.mr0xf00.easycrop.utils.polygonPath + +/** + * A Shape used to clip the resulting image. + * Implementations should provide a meaningful equals method, + * such as (A == B) => A.asPath(R) == B.asPath(R) + */ +@Stable +fun interface CropShape { + fun asPath(rect: Rect): Path +} + +@Stable +val RectCropShape = CropShape { rect -> Path().apply { addRect(rect) } } + +@Stable +val CircleCropShape = CropShape { rect -> Path().apply { addOval(rect) } } + +@Stable +val TriangleCropShape = CropShape { rect -> + Path().apply { + moveTo(rect.left, rect.bottom) + lineTo(rect.center.x, rect.top) + lineTo(rect.right, rect.bottom) + close() + } +} + +val StarCropShape = CropShape { rect -> + polygonPath( + tx = rect.left, ty = rect.top, + sx = rect.width / 32, sy = rect.height / 32, + points = floatArrayOf( + 31.95f, 12.418856f, + 20.63289f, 11.223692f, + 16f, 0.83228856f, + 11.367113f, 11.223692f, + 0.05000003f, 12.418856f, + 8.503064f, 20.03748f, + 6.1431603f, 31.167711f, + 16f, 25.48308f, + 25.85684f, 31.167711f, + 23.496937f, 20.03748f + ) + ) +} + +data class RoundRectCropShape(private val cornersPercent: Int) : CropShape { + init { + require(cornersPercent in 0..100) { "Corners percent must be in [0, 100]" } + } + + override fun asPath(rect: Rect): Path { + val radius = CornerRadius(rect.minDimension * cornersPercent / 100f) + return Path().apply { addRoundRect(RoundRect(rect = rect, radius)) } + } +} + +val DefaultCropShapes = listOf( + RectCropShape, CircleCropShape, RoundRectCropShape(15), + StarCropShape, TriangleCropShape +) diff --git a/easycrop/src/main/java/com/mr0xf00/easycrop/CropState.kt b/easycrop/src/main/java/com/mr0xf00/easycrop/CropState.kt new file mode 100644 index 00000000..712e5c11 --- /dev/null +++ b/easycrop/src/main/java/com/mr0xf00/easycrop/CropState.kt @@ -0,0 +1,126 @@ +package com.mr0xf00.easycrop + +import androidx.compose.runtime.* +import androidx.compose.ui.geometry.Rect +import androidx.compose.ui.geometry.Size +import androidx.compose.ui.geometry.toRect +import androidx.compose.ui.unit.IntSize +import androidx.compose.ui.unit.toIntRect +import androidx.compose.ui.unit.toSize +import com.mr0xf00.easycrop.utils.* +import com.mr0xf00.easycrop.utils.constrainOffset +import com.mr0xf00.easycrop.utils.eq +import com.mr0xf00.easycrop.utils.next90 +import com.mr0xf00.easycrop.utils.prev90 +import com.mr0xf00.easycrop.images.ImageSrc + +/** State for the current image being cropped */ +@Stable +interface CropState { + val src: ImageSrc + var transform: ImgTransform + var region: Rect + var aspectLock: Boolean + var shape: CropShape + val accepted: Boolean + fun done(accept: Boolean) + fun reset() +} + +internal fun CropState( + src: ImageSrc, + onDone: () -> Unit = {}, +): CropState = object : CropState { + val defaultTransform: ImgTransform = ImgTransform.Identity + val defaultShape: CropShape = RectCropShape + val defaultAspectLock: Boolean = false + override val src: ImageSrc get() = src + private var _transform: ImgTransform by mutableStateOf(defaultTransform) + override var transform: ImgTransform + get() = _transform + set(value) { + onTransformUpdated(transform, value) + _transform = value + } + + val defaultRegion = src.size.toSize().toRect() + + private var _region by mutableStateOf(defaultRegion) + override var region + get() = _region + set(value) { +// _region = value + _region = updateRegion( + old = _region, new = value, + bounds = imgRect, aspectLock = aspectLock + ) + } + + val imgRect by derivedStateOf { getTransformedImageRect(transform, src.size) } + + override var shape: CropShape by mutableStateOf(defaultShape) + override var aspectLock by mutableStateOf(defaultAspectLock) + + private fun onTransformUpdated(old: ImgTransform, new: ImgTransform) { + val unTransform = old.asMatrix(src.size).apply { invert() } + _region = new.asMatrix(src.size).map(unTransform.map(region)) + } + + override fun reset() { + transform = defaultTransform + shape = defaultShape + _region = defaultRegion + aspectLock = defaultAspectLock + } + + override var accepted: Boolean by mutableStateOf(false) + + override fun done(accept: Boolean) { + accepted = accept + onDone() + } +} + +internal fun getTransformedImageRect(transform: ImgTransform, size: IntSize): Rect { + val dstMat = transform.asMatrix(size) + return dstMat.map(size.toIntRect().toRect()) +} + +internal fun CropState.rotLeft() { + transform = transform.copy(angleDeg = transform.angleDeg.prev90()) +} + +internal fun CropState.rotRight() { + transform = transform.copy(angleDeg = transform.angleDeg.next90()) +} + +internal fun CropState.flipHorizontal() { + if ((transform.angleDeg / 90) % 2 == 0) flipX() else flipY() +} + +internal fun CropState.flipVertical() { + if ((transform.angleDeg / 90) % 2 == 0) flipY() else flipX() +} + +internal fun CropState.flipX() { + transform = transform.copy(scale = transform.scale.copy(x = -1 * transform.scale.x)) +} + +internal fun CropState.flipY() { + transform = transform.copy(scale = transform.scale.copy(y = -1 * transform.scale.y)) +} + +internal fun updateRegion(old: Rect, new: Rect, bounds: Rect, aspectLock: Boolean): Rect { + val offsetOnly = old.width.eq(new.width) && old.height.eq(new.height) + return if (offsetOnly) new.constrainOffset(bounds) + else { + val result = when { + aspectLock -> new.keepAspect(old).scaleToFit(bounds, old) + else -> new.constrainResize(bounds) + } + return when { + result.isEmpty -> result.setSize(old, Size(1f, 1f)).constrainOffset(bounds) + else -> result + } + } +} \ No newline at end of file diff --git a/easycrop/src/main/java/com/mr0xf00/easycrop/CropperStyle.kt b/easycrop/src/main/java/com/mr0xf00/easycrop/CropperStyle.kt new file mode 100644 index 00000000..1b0b8ceb --- /dev/null +++ b/easycrop/src/main/java/com/mr0xf00/easycrop/CropperStyle.kt @@ -0,0 +1,173 @@ +package com.mr0xf00.easycrop + +import androidx.compose.runtime.Stable +import androidx.compose.runtime.staticCompositionLocalOf +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.geometry.Rect +import androidx.compose.ui.graphics.ClipOp +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.StrokeCap +import androidx.compose.ui.graphics.drawscope.DrawScope +import androidx.compose.ui.graphics.drawscope.Stroke +import androidx.compose.ui.graphics.drawscope.clipRect +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp + +/** Image Aspect ratio. eg : AspectRatio(16, 9) */ +data class AspectRatio(val x: Int, val y: Int) + +data class CropperStyleGuidelines( + val count: Int = 2, + val color: Color = Color.White, + val width: Dp = .7f.dp, +) + +/** + * Style provider for the image cropper. + */ +@Stable +interface CropperStyle { + /** Backdrop for the image */ + val backgroundColor: Color + + /** Overlay color for regions outside of the crop rect */ + val overlayColor: Color + + /** Draws the crop rect [region], including the border and handles */ + fun DrawScope.drawCropRect(region: Rect) + + + /** Relative positions of the handles used for transforming the crop rect */ + val handles: List + + /** The maximum distance between a handle's center and the touch position + * for it to be selected */ + val touchRad: Dp + + /** All available crop shapes */ + val shapes: List? + + /** All available aspect ratios */ + val aspects: List + + /** Whether the view needs to be zoomed in automatically + * to fit the crop rect after any change */ + val autoZoom: Boolean +} + +val DefaultCropperStyle: CropperStyle by lazy { CropperStyle() } + +val LocalCropperStyle = staticCompositionLocalOf { DefaultCropperStyle } + +private val MainHandles = listOf( + Offset(0f, 0f), Offset(1f, 1f), + Offset(1f, 0f), Offset(0f, 1f) +) + +private val SecondaryHandles = listOf( + Offset(.5f, 0f), Offset(1f, .5f), + Offset(.5f, 1f), Offset(0f, .5f) +) + +private val AllHandles = MainHandles + SecondaryHandles + +private val DefaultAspectRatios = listOf( + AspectRatio(1, 1), + AspectRatio(16, 9), + AspectRatio(4, 3) +) + +/** Creates a [CropperStyle] instance with the default behavior. */ +fun CropperStyle( + backgroundColor: Color = Color.Black, + rectColor: Color = Color.White, + rectStrokeWidth: Dp = 2.dp, + touchRad: Dp = 20.dp, + guidelines: CropperStyleGuidelines? = CropperStyleGuidelines(), + secondaryHandles: Boolean = true, + overlay: Color = Color.Black.copy(alpha = .5f), + shapes: List? = DefaultCropShapes, + aspects: List = DefaultAspectRatios, + autoZoom: Boolean = true, +): CropperStyle = object : CropperStyle { + override val touchRad: Dp get() = touchRad + override val backgroundColor: Color get() = backgroundColor + override val overlayColor: Color get() = overlay + override val shapes: List? get() = shapes?.takeIf { it.isNotEmpty() } + override val aspects get() = aspects + override val autoZoom: Boolean get() = autoZoom + + override fun DrawScope.drawCropRect(region: Rect) { + val strokeWidth = rectStrokeWidth.toPx() + val finalRegion = region.inflate(strokeWidth / 2) + if (finalRegion.isEmpty) return + if (guidelines != null && guidelines.count > 0) { + drawGuidelines(guidelines, finalRegion) + } + drawRect( + color = rectColor, style = Stroke(strokeWidth), + topLeft = finalRegion.topLeft, size = finalRegion.size + ) + drawHandles(finalRegion) + } + + override val handles: List = if (!secondaryHandles) MainHandles else AllHandles + + private fun DrawScope.drawHandles(region: Rect) { + val strokeWidth = (rectStrokeWidth * 3).toPx() + val rad = touchRad.toPx() / 2 + val cap = StrokeCap.Round + + handles.forEach { (xRel, yRel) -> + val x = region.left + xRel * region.width + val y = region.top + yRel * region.height + when { + xRel != .5f && yRel != .5f -> { + drawCircle(color = rectColor, radius = rad, center = Offset(x, y)) + } + xRel == 0f || xRel == 1f -> if (region.height > rad * 4) drawLine( + rectColor, strokeWidth = strokeWidth, + start = Offset(x, (y - rad)), + end = Offset(x, (y + rad)), cap = cap + ) + yRel == 0f || yRel == 1f -> if (region.width > rad * 4) drawLine( + rectColor, strokeWidth = strokeWidth, + start = Offset((x - rad), y), + end = Offset((x + rad), y), cap = cap + ) + } + } + } + + private fun DrawScope.drawGuidelines( + guidelines: CropperStyleGuidelines, + region: Rect + ) = clipRect(rect = region) { + val strokeWidth = guidelines.width.toPx() + val xStep = region.width / (guidelines.count + 1) + val yStep = region.height / (guidelines.count + 1) + for (i in 1..guidelines.count) { + val x = region.left + i * xStep + val y = region.top + i * yStep + drawLine( + color = guidelines.color, strokeWidth = strokeWidth, + start = Offset(x, 0f), end = Offset(x, size.height) + ) + drawLine( + color = guidelines.color, strokeWidth = strokeWidth, + start = Offset(0f, y), end = Offset(size.width, y) + ) + } + } +} + +private inline fun DrawScope.clipRect( + rect: Rect, + op: ClipOp = ClipOp.Intersect, + block: DrawScope.() -> Unit +) { + clipRect( + left = rect.left, top = rect.top, right = rect.right, bottom = rect.bottom, + clipOp = op, block = block + ) +} diff --git a/easycrop/src/main/java/com/mr0xf00/easycrop/ImageCropper.android.kt b/easycrop/src/main/java/com/mr0xf00/easycrop/ImageCropper.android.kt new file mode 100644 index 00000000..e88849c8 --- /dev/null +++ b/easycrop/src/main/java/com/mr0xf00/easycrop/ImageCropper.android.kt @@ -0,0 +1,73 @@ +package com.mr0xf00.easycrop + +import android.content.Context +import android.net.Uri +import androidx.compose.ui.unit.IntSize +import com.mr0xf00.easycrop.images.toImageSrc +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.DelicateCoroutinesApi +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import java.io.File +import java.util.UUID + +/** + * Initiates a new crop session, cancelling the current one, if any. + * Suspends until a result is available (cancellation, error, success) and returns it. + * The resulting image will be scaled down to fit [maxResultSize] if provided. + * [file] will be used as a source. + */ +suspend fun ImageCropper.crop( + file: File, maxResultSize: IntSize? = DefaultMaxCropSize, +): CropResult { + return crop(maxResultSize) { file.toImageSrc() } +} + +/** + * Initiates a new crop session, cancelling the current one, if any. + * Suspends until a result is available (cancellation, error, success) and returns it. + * The resulting image will be scaled down to fit [maxResultSize] if provided. + * [uri] will be used as a source. + * Set [cacheBeforeUse] to false if you're certain that reopening it multiple times won't be a problem, + * true otherwise. + */ +suspend fun ImageCropper.crop( + uri: Uri, + context: Context, + maxResultSize: IntSize? = DefaultMaxCropSize, + cacheBeforeUse: Boolean = true +): CropResult = cacheUri(enabled = cacheBeforeUse, uri, context) { cached -> + crop(maxResultSize) { cached?.toImageSrc(context) } +} + +private const val CacheDir = "easycrop_cache" + +private suspend fun cacheUri( + enabled: Boolean, uri: Uri, context: Context, + block: suspend (Uri?) -> R +): R { + if (!enabled) return block(uri) + val dst = context.cacheDir.resolve("$CacheDir/${UUID.randomUUID()}") + return try { + val cached = runCatching { copy(uri, dst, context) }.getOrNull() + block(cached) + } finally { + dst.deleteInBackground() + } +} + +@OptIn(DelicateCoroutinesApi::class) +private fun File.deleteInBackground() { + CoroutineScope(Dispatchers.IO).launch { runCatching { delete() } } +} + +private suspend fun copy(src: Uri, dst: File, context: Context) = withContext(Dispatchers.IO) { + dst.parentFile?.mkdirs() + context.contentResolver.openInputStream(src)!!.use { srcStream -> + dst.outputStream().use { dstStream -> + srcStream.copyTo(dstStream) + } + } + Uri.fromFile(dst) +} \ No newline at end of file diff --git a/easycrop/src/main/java/com/mr0xf00/easycrop/ImageCropper.kt b/easycrop/src/main/java/com/mr0xf00/easycrop/ImageCropper.kt new file mode 100644 index 00000000..c7ab82ae --- /dev/null +++ b/easycrop/src/main/java/com/mr0xf00/easycrop/ImageCropper.kt @@ -0,0 +1,114 @@ +package com.mr0xf00.easycrop + +import androidx.compose.runtime.* +import androidx.compose.ui.graphics.ImageBitmap +import androidx.compose.ui.graphics.asAndroidBitmap +import androidx.compose.ui.unit.IntSize +import com.mr0xf00.easycrop.images.ImageBitmapSrc +import com.mr0xf00.easycrop.images.ImageSrc +import kotlinx.coroutines.flow.collect +import kotlinx.coroutines.flow.takeWhile +/** Union type denoting the possible results after a crop operation is done */ +sealed interface CropResult { + /** The final result as an ImageBitmap. + * use [asAndroidBitmap] if you need an [android.graphics.Bitmap]. + */ + data class Success(val bitmap: ImageBitmap) : CropResult + + /** The user has cancelled the operation or another session was started. */ + object Cancelled : CropResult +} + +enum class CropError : CropResult { + /** The supplied image is invalid, not supported by the codec + * or you don't have the required permissions to read it */ + LoadingError, + /** The result could not be saved. Try reducing the maxSize supplied to [ImageCropper.crop] */ + SavingError +} + +enum class CropperLoading { + /** The image is being prepared. */ + PreparingImage, + + /** The user has accepted the cropped image and the result is being saved. */ + SavingResult, +} + +internal val DefaultMaxCropSize = IntSize(3000, 3000) + +/** + * State holder for the image cropper. + * Allows starting new crop sessions as well as getting the state of the pending crop. + */ +@Stable +interface ImageCropper { + /** The pending crop state, if any */ + val cropState: CropState? + + val loadingStatus: CropperLoading? + + /** + * Initiates a new crop session, cancelling the current one, if any. + * Suspends until a result is available (cancellation, error, success) and returns it. + * The resulting image will be scaled down to fit [maxResultSize] (if provided). + * [createSrc] will be used to construct an [ImageSrc] instance. + */ + suspend fun crop( + maxResultSize: IntSize? = DefaultMaxCropSize, + createSrc: suspend () -> ImageSrc? + ): CropResult +} + +/** + * Initiates a new crop session, cancelling the current one, if any. + * Suspends until a result is available (cancellation, error, success) and returns it. + * The resulting image will be scaled down to fit [maxResultSize] if provided. + * [bmp] will be used as a source. + */ +suspend fun ImageCropper.crop( + maxResultSize: IntSize? = DefaultMaxCropSize, + bmp: ImageBitmap +): CropResult = crop(maxResultSize = maxResultSize) { + ImageBitmapSrc(bmp) +} + +@Composable +fun rememberImageCropper() : ImageCropper { + return remember { ImageCropper() } +} + +/** + * Creates an [ImageCropper] instance. + */ +fun ImageCropper(): ImageCropper = object : ImageCropper { + override var cropState: CropState? by mutableStateOf(null) + private val cropStateFlow = snapshotFlow { cropState } + override var loadingStatus: CropperLoading? by mutableStateOf(null) + override suspend fun crop( + maxResultSize: IntSize?, + createSrc: suspend () -> ImageSrc? + ): CropResult { + cropState = null + val src = withLoading(CropperLoading.PreparingImage) { createSrc() } + ?: return CropError.LoadingError + val newCrop = CropState(src) { cropState = null } + cropState = newCrop + cropStateFlow.takeWhile { it === newCrop }.collect() + if (!newCrop.accepted) return CropResult.Cancelled + return withLoading(CropperLoading.SavingResult) { + val result = newCrop.createResult(maxResultSize) + if (result == null) CropError.SavingError + else CropResult.Success(result) + } + } + + inline fun withLoading(status: CropperLoading, op: () -> R): R { + return try { + loadingStatus = status + op() + } finally { + loadingStatus = null + } + } +} \ No newline at end of file diff --git a/easycrop/src/main/java/com/mr0xf00/easycrop/ImagePicker.kt b/easycrop/src/main/java/com/mr0xf00/easycrop/ImagePicker.kt new file mode 100644 index 00000000..ef41c302 --- /dev/null +++ b/easycrop/src/main/java/com/mr0xf00/easycrop/ImagePicker.kt @@ -0,0 +1,27 @@ +package com.mr0xf00.easycrop + +import android.net.Uri +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.contract.ActivityResultContracts +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember + +interface ImagePicker { + /** Pick an image with [mimetype] */ + fun pick(mimetype: String = "image/*") +} + +/** Creates and remembers a instance of [ImagePicker] that launches + * [ActivityResultContracts.GetContent] and calls [onImage] when the result is available */ +@Composable +fun rememberImagePicker(onImage: (uri: Uri) -> Unit): ImagePicker { + val contract = remember { ActivityResultContracts.GetContent() } + val launcher = rememberLauncherForActivityResult( + contract = contract, + onResult = { if (it != null) onImage(it) }) + return remember { + object : ImagePicker { + override fun pick(mimetype: String) = launcher.launch(mimetype) + } + } +} \ No newline at end of file diff --git a/easycrop/src/main/java/com/mr0xf00/easycrop/ImgTransform.kt b/easycrop/src/main/java/com/mr0xf00/easycrop/ImgTransform.kt new file mode 100644 index 00000000..942ce8c6 --- /dev/null +++ b/easycrop/src/main/java/com/mr0xf00/easycrop/ImgTransform.kt @@ -0,0 +1,30 @@ +package com.mr0xf00.easycrop + +import androidx.compose.runtime.Stable +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.graphics.Matrix +import androidx.compose.ui.unit.IntSize +import com.mr0xf00.easycrop.utils.IdentityMat + +/** + * Transformation applied on an image with [pivotRel] as pivot's relative position. + */ +data class ImgTransform(val angleDeg: Int, val scale: Offset, val pivotRel: Offset) { + internal val hasTransform get() = angleDeg != 0 || scale != Offset(1f, 1f) + + companion object { + @Stable + internal val Identity = ImgTransform(0, Offset(1f, 1f), Offset(.5f, .5f)) + } +} + +internal fun ImgTransform.asMatrix(imgSize: IntSize): Matrix { + if (!hasTransform) return IdentityMat + val matrix = Matrix() + val pivot = Offset(imgSize.width * pivotRel.x, imgSize.height * pivotRel.y) + matrix.translate(pivot.x, pivot.y) + matrix.rotateZ(angleDeg.toFloat()) + matrix.scale(scale.x, scale.y) + matrix.translate(-pivot.x, -pivot.y) + return matrix +} \ No newline at end of file diff --git a/easycrop/src/main/java/com/mr0xf00/easycrop/Result.kt b/easycrop/src/main/java/com/mr0xf00/easycrop/Result.kt new file mode 100644 index 00000000..72a3e49b --- /dev/null +++ b/easycrop/src/main/java/com/mr0xf00/easycrop/Result.kt @@ -0,0 +1,55 @@ +package com.mr0xf00.easycrop + +import androidx.compose.ui.geometry.toRect +import androidx.compose.ui.graphics.Canvas +import androidx.compose.ui.graphics.FilterQuality +import androidx.compose.ui.graphics.ImageBitmap +import androidx.compose.ui.graphics.Paint +import androidx.compose.ui.unit.IntSize +import androidx.compose.ui.unit.toSize +import com.mr0xf00.easycrop.images.getDecodeParams +import com.mr0xf00.easycrop.utils.* +import com.mr0xf00.easycrop.utils.ViewMat +import com.mr0xf00.easycrop.utils.atOrigin +import com.mr0xf00.easycrop.utils.coerceAtMost +import com.mr0xf00.easycrop.utils.roundUp +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext + +/** + * Creates an [ImageBitmap] using the parameters in [CropState]. + * If [maxSize] is not null, the result will be scaled down to match it. + * Returns null if the image could not be created. + */ +suspend fun CropState.createResult( + maxSize: IntSize? +): ImageBitmap? = withContext(Dispatchers.Default) { + runCatching { doCreateResult(maxSize) } + .onFailure { it.printStackTrace() } + .getOrNull() +} + +private suspend fun CropState.doCreateResult(maxSize: IntSize?): ImageBitmap? { + val finalSize = region.size + .coerceAtMost(maxSize?.toSize()) + .roundUp() + val result = ImageBitmap(finalSize.width, finalSize.height) + val canvas = Canvas(result) + val viewMat = ViewMat() + viewMat.snapFit(region, finalSize.toSize().toRect()) + val imgMat = transform.asMatrix(src.size) + val totalMat = imgMat * viewMat.matrix + + canvas.clipPath(shape.asPath(region.atOrigin())) + canvas.concat(totalMat) + val inParams = getDecodeParams(view = finalSize, img = src.size, totalMat) + ?: return null + val decoded = src.open(inParams) ?: return null + val paint = Paint().apply { filterQuality = FilterQuality.High } + canvas.drawImageRect( + image = decoded.bmp, paint = paint, + dstOffset = decoded.params.subset.topLeft, + dstSize = decoded.params.subset.size, + ) + return result +} diff --git a/easycrop/src/main/java/com/mr0xf00/easycrop/Touch.kt b/easycrop/src/main/java/com/mr0xf00/easycrop/Touch.kt new file mode 100644 index 00000000..943837d6 --- /dev/null +++ b/easycrop/src/main/java/com/mr0xf00/easycrop/Touch.kt @@ -0,0 +1,87 @@ +package com.mr0xf00.easycrop + +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.composed +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.geometry.Rect +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.round +import androidx.compose.ui.unit.toOffset +import com.mr0xf00.easycrop.utils.ViewMat +import com.mr0xf00.easycrop.utils.abs +import com.mr0xf00.easycrop.utils.DragState +import com.mr0xf00.easycrop.utils.ZoomState +import com.mr0xf00.easycrop.utils.onGestures +import com.mr0xf00.easycrop.utils.rememberGestureState +import com.mr0xf00.easycrop.utils.resize + + +private val MoveHandle = Offset(.5f, .5f) + +internal class DragHandle( + val handle: Offset, + val initialPos: Offset, + val initialRegion: Rect +) + +internal fun Modifier.cropperTouch( + region: Rect, + onRegion: (Rect) -> Unit, + touchRad: Dp, + handles: List, + viewMat: ViewMat, + pending: DragHandle?, + onPending: (DragHandle?) -> Unit, +): Modifier = composed { + val touchRadPx2 = LocalDensity.current.run { + remember(touchRad, viewMat.scale) { touchRad.toPx() / viewMat.scale }.let { it * it } + } + MaterialTheme + onGestures( + rememberGestureState( + zoom = ZoomState( + begin = { c -> viewMat.zoomStart(c) }, + next = { s, c -> viewMat.zoom(c, s) }, + ), + drag = DragState( + begin = { pos -> + val localPos = viewMat.invMatrix.map(pos) + handles.findHandle( + region, localPos, + touchRadPx2 + )?.let { handle -> + onPending(DragHandle(handle, localPos, region)) + } + }, + next = { _, pos, _ -> + pending?.let { + val localPos = viewMat.invMatrix.map(pos) + val delta = (localPos - pending.initialPos).round().toOffset() + val newRegion = if (pending.handle != MoveHandle) { + pending.initialRegion + .resize(pending.handle, delta) + } else { + pending.initialRegion.translate(delta) + } + onRegion(newRegion) + } + }, + done = { + onPending(null) + }) + ) + ) +} + +private fun List.findHandle( + region: Rect, + pos: Offset, + touchRadPx2: Float +): Offset? { + firstOrNull { (region.abs(it) - pos).getDistanceSquared() <= touchRadPx2 }?.let { return it } + if (region.contains(pos)) return MoveHandle + return null +} \ No newline at end of file diff --git a/easycrop/src/main/java/com/mr0xf00/easycrop/TransformAnimation.kt b/easycrop/src/main/java/com/mr0xf00/easycrop/TransformAnimation.kt new file mode 100644 index 00000000..be9e00ce --- /dev/null +++ b/easycrop/src/main/java/com/mr0xf00/easycrop/TransformAnimation.kt @@ -0,0 +1,33 @@ +package com.mr0xf00.easycrop + +import androidx.compose.animation.core.animate +import androidx.compose.runtime.* +import androidx.compose.ui.geometry.lerp +import com.mr0xf00.easycrop.utils.lerpAngle + +@Composable +internal fun animateImgTransform(target: ImgTransform): State { + var prev by remember { mutableStateOf(null) } + val current = remember { mutableStateOf(target) } + LaunchedEffect(target) { + val a = prev + try { + if (a != null) animate(0f, 1f) { p, _ -> + current.value = (a.lerp(target, p)) + } + } finally { + current.value = target.also { prev = it } + } + } + return current +} + +private fun ImgTransform.lerp(target: ImgTransform, p: Float): ImgTransform { + if (p == 0f) return this + if (p == 1f) return target + return ImgTransform( + angleDeg = lerpAngle(angleDeg, target.angleDeg, p), + scale = lerp(scale, target.scale, p), + pivotRel = lerp(pivotRel, target.pivotRel, p) + ) +} \ No newline at end of file diff --git a/easycrop/src/main/java/com/mr0xf00/easycrop/images/Decode.kt b/easycrop/src/main/java/com/mr0xf00/easycrop/images/Decode.kt new file mode 100644 index 00000000..05bf9a3d --- /dev/null +++ b/easycrop/src/main/java/com/mr0xf00/easycrop/images/Decode.kt @@ -0,0 +1,50 @@ +package com.mr0xf00.easycrop.images + +import androidx.compose.ui.geometry.toRect +import androidx.compose.ui.graphics.ImageBitmap +import androidx.compose.ui.graphics.Matrix +import androidx.compose.ui.unit.IntRect +import androidx.compose.ui.unit.IntSize +import androidx.compose.ui.unit.toIntRect +import androidx.compose.ui.unit.toSize +import com.mr0xf00.easycrop.utils.* +import com.mr0xf00.easycrop.utils.containsInclusive + +data class DecodeParams(val sampleSize: Int, val subset: IntRect) +data class DecodeResult(val params: DecodeParams, val bmp: ImageBitmap) + +internal fun calculateSampleSize(imgRegion: IntSize, view: IntSize): Int { + val ratio = + (imgRegion.width.toDouble() / view.width) * (imgRegion.height.toDouble() / view.height) + return ratio.toFloat().align(2).coerceIn(1f, 32f).toInt() +} + +private fun getImageSubset( + view: IntSize, viewToImg: Matrix, imgRect: IntRect, align: Boolean +): IntRect { + return viewToImg + .map(view.toSize().toRect()).let { if (align) it.align(128) else it } + .roundOut().intersect(imgRect) +} + +internal fun getDecodeParams( + view: IntSize, + img: IntSize, + imgToView: Matrix +): DecodeParams? { + if (view.width <= 0 || view.height <= 0) return null + val imgRect = img.toIntRect() + val viewToImg = imgToView.inverted() + val subset = getImageSubset(view, viewToImg, imgRect, align = true) + if (subset.isEmpty) return null + val sampleSize = calculateSampleSize( + imgRegion = getImageSubset(view, viewToImg, imgRect, align = false).size, + view = view + ) + return DecodeParams(sampleSize, subset) +} + +internal fun DecodeParams.contains(other: DecodeParams): Boolean { + return sampleSize == other.sampleSize && + subset.containsInclusive(other.subset) +} \ No newline at end of file diff --git a/easycrop/src/main/java/com/mr0xf00/easycrop/images/ImageLoader.kt b/easycrop/src/main/java/com/mr0xf00/easycrop/images/ImageLoader.kt new file mode 100644 index 00000000..7152b031 --- /dev/null +++ b/easycrop/src/main/java/com/mr0xf00/easycrop/images/ImageLoader.kt @@ -0,0 +1,40 @@ +package com.mr0xf00.easycrop.images + +import androidx.compose.runtime.* +import androidx.compose.ui.geometry.toRect +import androidx.compose.ui.graphics.Matrix +import androidx.compose.ui.unit.IntSize +import androidx.compose.ui.unit.toSize +import com.mr0xf00.easycrop.utils.fitIn +import com.mr0xf00.easycrop.utils.setRectToRect +import kotlinx.coroutines.delay +import kotlinx.coroutines.yield + +@Composable +internal fun rememberLoadedImage(src: ImageSrc, view: IntSize, imgToView: Matrix): DecodeResult? { + // TODO provide better caching system + var full by remember { mutableStateOf(null) } + var enhanced by remember { mutableStateOf(null) } + LaunchedEffect(src, view) { + val fullMat = Matrix().apply { + val imgRect = src.size.toSize().toRect() + setRectToRect(imgRect, imgRect.fitIn(view.toSize().toRect())) + } + val fullParams = getDecodeParams(view, src.size, fullMat) + if (fullParams != null) full = src.open(fullParams) + } + LaunchedEffect(src, view, imgToView, full == null) decode@{ + if (full == null) return@decode + if (enhanced == null) yield() + val params = getDecodeParams(view, src.size, imgToView) ?: return@decode + if (enhanced?.params?.contains(params) == true) return@decode + if (full?.params?.contains(params) == true) { + enhanced = full + return@decode + } + enhanced = null + delay(500) + enhanced = src.open(params) + } + return enhanced ?: full +} \ No newline at end of file diff --git a/easycrop/src/main/java/com/mr0xf00/easycrop/images/ImageSrc.kt b/easycrop/src/main/java/com/mr0xf00/easycrop/images/ImageSrc.kt new file mode 100644 index 00000000..50d82d72 --- /dev/null +++ b/easycrop/src/main/java/com/mr0xf00/easycrop/images/ImageSrc.kt @@ -0,0 +1,18 @@ +package com.mr0xf00.easycrop.images + +import androidx.compose.ui.graphics.ImageBitmap +import androidx.compose.ui.unit.IntSize +import androidx.compose.ui.unit.toIntRect +import androidx.compose.runtime.Stable + +@Stable +interface ImageSrc { + val size: IntSize + suspend fun open(params: DecodeParams): DecodeResult? +} + +internal data class ImageBitmapSrc(private val data: ImageBitmap) : ImageSrc { + override val size: IntSize = IntSize(data.width, data.height) + private val resultParams = DecodeParams(1, size.toIntRect()) + override suspend fun open(params: DecodeParams) = DecodeResult(resultParams, data) +} \ No newline at end of file diff --git a/easycrop/src/main/java/com/mr0xf00/easycrop/images/ImageStream.android.kt b/easycrop/src/main/java/com/mr0xf00/easycrop/images/ImageStream.android.kt new file mode 100644 index 00000000..6f031442 --- /dev/null +++ b/easycrop/src/main/java/com/mr0xf00/easycrop/images/ImageStream.android.kt @@ -0,0 +1,28 @@ +package com.mr0xf00.easycrop.images + +import android.content.Context +import android.net.Uri +import java.io.File +import java.io.InputStream + +internal fun interface ImageStream { + fun openStream(): InputStream? +} + +internal suspend fun Uri.toImageSrc(context: Context) = + ImageStreamSrc(UriImageStream(this, context)) + +internal suspend fun File.toImageSrc() = ImageStreamSrc(FileImageStream(this)) + +internal data class FileImageStream(val file: File) : ImageStream { + override fun openStream(): InputStream = try { + file.inputStream() + } catch (e: Exception) { + throw IllegalArgumentException("Failed to open file stream", e) + } +} + +internal data class UriImageStream(val uri: Uri, val context: Context) : ImageStream { + override fun openStream(): InputStream? = context.contentResolver.openInputStream(uri) +} + diff --git a/easycrop/src/main/java/com/mr0xf00/easycrop/images/ImageStreamSrc.android.kt b/easycrop/src/main/java/com/mr0xf00/easycrop/images/ImageStreamSrc.android.kt new file mode 100644 index 00000000..326d1ebf --- /dev/null +++ b/easycrop/src/main/java/com/mr0xf00/easycrop/images/ImageStreamSrc.android.kt @@ -0,0 +1,92 @@ +package com.mr0xf00.easycrop.images + +import android.graphics.Bitmap +import android.graphics.BitmapFactory +import android.graphics.BitmapRegionDecoder +import androidx.compose.ui.graphics.asImageBitmap +import androidx.compose.ui.unit.IntRect +import androidx.compose.ui.unit.IntSize +import androidx.compose.ui.unit.toIntRect +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import java.io.InputStream +import java.util.concurrent.atomic.AtomicBoolean + +internal data class ImageStreamSrc( + private val dataSource: ImageStream, + override val size: IntSize +) : ImageSrc { + + private val allowRegion = AtomicBoolean(true) + + private suspend fun openRegion(params: DecodeParams): DecodeResult? { + return dataSource.tryUse { stream -> + regionDecoder(stream)!!.decodeRegion(params) + }?.let { bmp -> + DecodeResult(params, bmp.asImageBitmap()) + } + } + + private suspend fun openFull(sampleSize: Int): DecodeResult? { + //BitmapFactory.decode supports more formats than BitmapRegionDecoder. + return dataSource.tryUse { stream -> + val options = BitmapFactory.Options().apply { inSampleSize = sampleSize } + BitmapFactory.decodeStream(stream, null, options) + }?.let { bmp -> + DecodeResult(DecodeParams(sampleSize, size.toIntRect()), bmp.asImageBitmap()) + } + } + + override suspend fun open(params: DecodeParams): DecodeResult? { + if (allowRegion.get()) { + val region = openRegion(params) + if (region != null) return region + else allowRegion.set(false) + } + openFull(params.sampleSize)?.let { return it } + return null + } + + companion object { + private suspend fun ImageStream.tryUse(op: (InputStream) -> R): R? { + return withContext(Dispatchers.IO) { + openStream()?.use { stream -> runCatching { op(stream) } } + }?.onFailure { + it.printStackTrace() + }?.getOrNull() + } + + suspend operator fun invoke(dataSource: ImageStream): ImageStreamSrc? { + val size = dataSource.tryUse { it.getImageSize() } + ?.takeIf { it.width > 0 && it.height > 0 } + ?: return null + return ImageStreamSrc(dataSource, size) + } + } +} + +private fun regionDecoder(stream: InputStream): BitmapRegionDecoder? { + @Suppress("DEPRECATION") + return BitmapRegionDecoder.newInstance(stream, false) +} + +private fun BitmapRegionDecoder.decodeRegion(params: DecodeParams): Bitmap? { + val rect = params.subset.toAndroidRect() + val options = bitmapFactoryOptions(params.sampleSize) + return decodeRegion(rect, options) +} + +private fun IntRect.toAndroidRect(): android.graphics.Rect { + return android.graphics.Rect(left, top, right, bottom) +} + +private fun bitmapFactoryOptions(sampleSize: Int) = BitmapFactory.Options().apply { + inMutable = false + inSampleSize = sampleSize +} + +private fun InputStream.getImageSize(): IntSize { + val options = BitmapFactory.Options().apply { inJustDecodeBounds = true } + BitmapFactory.decodeStream(this, null, options) + return IntSize(options.outWidth, options.outHeight) +} \ No newline at end of file diff --git a/easycrop/src/main/java/com/mr0xf00/easycrop/ui/Controls.kt b/easycrop/src/main/java/com/mr0xf00/easycrop/ui/Controls.kt new file mode 100644 index 00000000..5a4079c3 --- /dev/null +++ b/easycrop/src/main/java/com/mr0xf00/easycrop/ui/Controls.kt @@ -0,0 +1,199 @@ +package com.mr0xf00.easycrop.ui + +import androidx.compose.animation.animateColorAsState +import androidx.compose.foundation.horizontalScroll +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.* +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.LocalContentColor +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.material3.contentColorFor +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.drawWithCache +import androidx.compose.ui.geometry.Rect +import androidx.compose.ui.geometry.Size +import androidx.compose.ui.geometry.toRect +import androidx.compose.ui.graphics.drawscope.Stroke +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.unit.dp +import com.mr0xf00.easycrop.* +import com.mr0xf00.easycrop.R +import com.mr0xf00.easycrop.utils.eq0 +import com.mr0xf00.easycrop.utils.setAspect + +private fun Size.isAspect(aspect: AspectRatio): Boolean { + return ((width / height) - (aspect.x.toFloat() / aspect.y)).eq0() +} + +internal val LocalVerticalControls = staticCompositionLocalOf { false } + +@Composable +internal fun CropperControls( + isVertical: Boolean, + state: CropState, + modifier: Modifier = Modifier +) { + CompositionLocalProvider(LocalVerticalControls provides isVertical) { + ButtonsBar(modifier = modifier) { + IconButton(onClick = { state.rotLeft() }) { + Icon(painter = painterResource(id = R.drawable.rot_left), contentDescription = null) + } + IconButton(onClick = { state.rotRight() }) { + Icon( + painter = painterResource(id = R.drawable.rot_right), + contentDescription = null + ) + } + IconButton(onClick = { state.flipHorizontal() }) { + Icon(painter = painterResource(id = R.drawable.flip_hor), contentDescription = null) + } + IconButton(onClick = { state.flipVertical() }) { + Icon(painter = painterResource(id = R.drawable.flip_ver), contentDescription = null) + } + Box { + var menu by remember { mutableStateOf(false) } + IconButton(onClick = { menu = !menu }) { + Icon( + painter = painterResource(id = R.drawable.resize), + contentDescription = null + ) + } + if (menu) AspectSelectionMenu( + onDismiss = { menu = false }, + region = state.region, + onRegion = { state.region = it }, + lock = state.aspectLock, + onLock = { state.aspectLock = it } + ) + } + LocalCropperStyle.current.shapes?.let { shapes -> + if (shapes.isNotEmpty()) { + Box { + var menu by remember { mutableStateOf(false) } + IconButton(onClick = { menu = !menu }) { + Icon(imageVector = Icons.Default.Star, contentDescription = null) + } + if (menu) ShapeSelectionMenu( + onDismiss = { menu = false }, + selected = state.shape, + onSelect = { state.shape = it }, + shapes = shapes + ) + } + } + } + } + } +} + +@Composable +private fun ButtonsBar( + modifier: Modifier = Modifier, + buttons: @Composable () -> Unit +) { + Surface( + modifier = modifier, + shape = CircleShape, + shadowElevation = 4.dp, + color = MaterialTheme.colorScheme.surface.copy(alpha = .8f), + contentColor = contentColorFor(MaterialTheme.colorScheme.surface) + ) { + if (LocalVerticalControls.current) Column( + modifier = Modifier.verticalScroll(rememberScrollState()), + verticalArrangement = Arrangement.spacedBy(8.dp, Alignment.CenterVertically) + ) { + buttons() + } else Row( + modifier = Modifier.horizontalScroll(rememberScrollState()), + horizontalArrangement = Arrangement.spacedBy(8.dp, Alignment.CenterHorizontally) + ) { + buttons() + } + } +} + + +@Composable +private fun ShapeSelectionMenu( + onDismiss: () -> Unit, + shapes: List, + selected: CropShape, + onSelect: (CropShape) -> Unit, +) { + OptionsPopup(onDismiss = onDismiss, optionCount = shapes.size) { i -> + val shape = shapes[i] + ShapeItem( + shape = shape, selected = selected == shape, + onSelect = { onSelect(shape) }) + } +} + + +@Composable +private fun ShapeItem( + shape: CropShape, selected: Boolean, onSelect: () -> Unit, + modifier: Modifier = Modifier +) { + val color by animateColorAsState( + targetValue = if (!selected) LocalContentColor.current + else MaterialTheme.colorScheme.secondary + ) + IconButton( + modifier = modifier, + onClick = onSelect + ) { + val shapeState by rememberUpdatedState(newValue = shape) + Box( + modifier = Modifier + .size(20.dp) + .drawWithCache { + val path = shapeState.asPath(size.toRect()) + val strokeWidth = 2.dp.toPx() + onDrawWithContent { + drawPath(path = path, color = color, style = Stroke(strokeWidth)) + } + }) + } +} + + +@Composable +private fun AspectSelectionMenu( + onDismiss: () -> Unit, + region: Rect, + onRegion: (Rect) -> Unit, + lock: Boolean, + onLock: (Boolean) -> Unit +) { + val aspects = LocalCropperStyle.current.aspects + if (aspects.isNotEmpty()) { + OptionsPopup(onDismiss = onDismiss, optionCount = 1 + aspects.size) { i -> + val unselectedTint = LocalContentColor.current + val selectedTint = MaterialTheme.colorScheme.secondary + if (i == 0) IconButton(onClick = { onLock(!lock) }) { + Icon( + imageVector = Icons.Default.Lock, contentDescription = null, + tint = if (lock) selectedTint else unselectedTint + ) + } else { + val aspect = aspects[i - 1] + val isSelected = region.size.isAspect(aspect) + IconButton(onClick = { onRegion(region.setAspect(aspect)) }) { + Text( + "${aspect.x}:${aspect.y}", + color = if (isSelected) selectedTint else unselectedTint + ) + } + } + } + } +} \ No newline at end of file diff --git a/easycrop/src/main/java/com/mr0xf00/easycrop/ui/CropperPreview.kt b/easycrop/src/main/java/com/mr0xf00/easycrop/ui/CropperPreview.kt new file mode 100644 index 00000000..310f236b --- /dev/null +++ b/easycrop/src/main/java/com/mr0xf00/easycrop/ui/CropperPreview.kt @@ -0,0 +1,101 @@ +package com.mr0xf00.easycrop.ui + +import androidx.compose.foundation.Canvas +import androidx.compose.foundation.background +import androidx.compose.runtime.* +import androidx.compose.ui.Modifier +import androidx.compose.ui.geometry.Rect +import androidx.compose.ui.geometry.toRect +import androidx.compose.ui.graphics.ClipOp +import androidx.compose.ui.graphics.drawscope.clipPath +import androidx.compose.ui.graphics.drawscope.withTransform +import androidx.compose.ui.layout.onGloballyPositioned +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.unit.IntSize +import androidx.compose.ui.unit.toSize +import com.mr0xf00.easycrop.* +import com.mr0xf00.easycrop.images.rememberLoadedImage +import com.mr0xf00.easycrop.utils.ViewMat +import com.mr0xf00.easycrop.utils.times +import kotlinx.coroutines.delay + +@Composable +fun CropperPreview( + state: CropState, + modifier: Modifier = Modifier +) { + val style = LocalCropperStyle.current + val imgTransform by animateImgTransform(target = state.transform) + val imgMat = remember(imgTransform, state.src.size) { imgTransform.asMatrix(state.src.size) } + val viewMat = remember { ViewMat() } + var view by remember { mutableStateOf(IntSize.Zero) } + var pendingDrag by remember { mutableStateOf(null) } + val viewPadding = LocalDensity.current.run { style.touchRad.toPx() } + val totalMat = remember(viewMat.matrix, imgMat) { imgMat * viewMat.matrix } + val image = rememberLoadedImage(state.src, view, totalMat) + val cropRect = remember(state.region, viewMat.matrix) { + viewMat.matrix.map(state.region) + } + val cropPath = remember(state.shape, cropRect) { state.shape.asPath(cropRect) } + BringToView( + enabled = style.autoZoom, + hasOverride = pendingDrag != null, + outer = view.toSize().toRect().deflate(viewPadding), + mat = viewMat, local = state.region, + ) + Canvas( + modifier = modifier + .onGloballyPositioned { view = it.size } + .background(color = style.backgroundColor) + .cropperTouch( + region = state.region, + onRegion = { state.region = it }, + touchRad = style.touchRad, handles = style.handles, + viewMat = viewMat, + pending = pendingDrag, + onPending = { pendingDrag = it }, + ) + ) { + withTransform({ transform(totalMat) }) { + image?.let { (params, bitmap) -> + drawImage( + bitmap, dstOffset = params.subset.topLeft, + dstSize = params.subset.size + ) + } + } + with(style) { + clipPath(cropPath, ClipOp.Difference) { + drawRect(color = overlayColor) + } + drawCropRect(cropRect) + } + } +} + +@Composable +private fun BringToView( + enabled: Boolean, + hasOverride: Boolean, + outer: Rect, + mat: ViewMat, + local: Rect +) { + if (outer.isEmpty) return + DisposableEffect(Unit) { + mat.snapFit(mat.matrix.map(local), outer) + onDispose { } + } + if (!enabled) return + var overrideBlock by remember { mutableStateOf(false) } + LaunchedEffect(hasOverride, outer, local) { + if (hasOverride) overrideBlock = true + else { + if (overrideBlock) { + delay(500) + overrideBlock = false + } + mat.fit(mat.matrix.map(local), outer) + } + } +} diff --git a/easycrop/src/main/java/com/mr0xf00/easycrop/ui/ImageCropperDialog.kt b/easycrop/src/main/java/com/mr0xf00/easycrop/ui/ImageCropperDialog.kt new file mode 100644 index 00000000..1592f044 --- /dev/null +++ b/easycrop/src/main/java/com/mr0xf00/easycrop/ui/ImageCropperDialog.kt @@ -0,0 +1,69 @@ +package com.mr0xf00.easycrop.ui + +import android.content.res.Configuration +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material.icons.filled.Done +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.Scaffold +import androidx.compose.material3.TopAppBar +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalConfiguration +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.unit.dp +import com.mr0xf00.easycrop.CropState +import com.mr0xf00.easycrop.R + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun ImageCropperDialog( + modifier: Modifier = Modifier, + state: CropState, +) { + Scaffold( + topBar = { + TopAppBar( + title = {}, + navigationIcon = { + IconButton(onClick = { state.done(accept = false) }) { + Icon(Icons.AutoMirrored.Filled.ArrowBack, null) + } + }, + actions = { + IconButton(onClick = { state.reset() }) { + Icon(painterResource(R.drawable.restore), null) + } + IconButton(onClick = { state.done(accept = true) }, enabled = !state.accepted) { + Icon(Icons.Default.Done, null) + } + } + ) + }, + ) { innerPadding -> + Box( + modifier = modifier + .padding(innerPadding) + .fillMaxSize() + ) { + CropperPreview(state = state, modifier = Modifier.fillMaxSize()) + val verticalControls = + LocalConfiguration.current.orientation == Configuration.ORIENTATION_LANDSCAPE + CropperControls( + isVertical = verticalControls, + state = state, + modifier = Modifier + .align(if (!verticalControls) Alignment.BottomCenter else Alignment.CenterEnd) + .padding(12.dp), + ) + } + } +} + + diff --git a/easycrop/src/main/java/com/mr0xf00/easycrop/ui/Popup.kt b/easycrop/src/main/java/com/mr0xf00/easycrop/ui/Popup.kt new file mode 100644 index 00000000..b1531890 --- /dev/null +++ b/easycrop/src/main/java/com/mr0xf00/easycrop/ui/Popup.kt @@ -0,0 +1,138 @@ +package com.mr0xf00.easycrop.ui + +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.LazyRow +import androidx.compose.foundation.shape.GenericShape +import androidx.compose.material3.Surface +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.geometry.CornerRadius +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.geometry.RoundRect +import androidx.compose.ui.geometry.Size +import androidx.compose.ui.geometry.toRect +import androidx.compose.ui.graphics.Path +import androidx.compose.ui.graphics.Shape +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.unit.IntOffset +import androidx.compose.ui.unit.IntRect +import androidx.compose.ui.unit.IntSize +import androidx.compose.ui.unit.LayoutDirection +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.toIntRect +import androidx.compose.ui.window.Popup +import androidx.compose.ui.window.PopupPositionProvider +import com.mr0xf00.easycrop.utils.constrainOffset + +private enum class PopupSide { + Start, End, Top, Bottom +} + +private val PopupSide.isHorizontal get() = this == PopupSide.Start || this == PopupSide.End +private fun PopupSide.isLeft(dir: LayoutDirection) = + (this == PopupSide.Start && dir == LayoutDirection.Ltr) || + (this == PopupSide.End && dir == LayoutDirection.Rtl) + +@Composable +private fun rememberPopupPos( + side: PopupSide, + onAnchorPos: (pos: IntOffset) -> Unit +) = object : PopupPositionProvider { + override fun calculatePosition( + anchorBounds: IntRect, + windowSize: IntSize, + layoutDirection: LayoutDirection, + popupContentSize: IntSize + ): IntOffset { + val popupRect = placePopup( + rect = popupContentSize.toIntRect(), + anchor = anchorBounds.inflate(anchorBounds.minDimension / 10), + side = side, + dir = layoutDirection + ).constrainOffset(windowSize.toIntRect()) + onAnchorPos(anchorBounds.center - popupRect.topLeft) + return popupRect.topLeft + } +} + +private fun placePopup( + rect: IntRect, + anchor: IntRect, + side: PopupSide, + dir: LayoutDirection +): IntRect { + val dx = when { + !side.isHorizontal -> anchor.center.x - rect.center.x + side.isLeft(dir) -> anchor.left - rect.right + else -> anchor.right - rect.left + } + val dy = when { + side.isHorizontal -> anchor.center.y - rect.center.y + side == PopupSide.Top -> anchor.top - rect.bottom + else -> anchor.bottom - rect.top + } + return rect.translate(dx, dy) +} + +@Composable +private fun popupShape(anchorPos: IntOffset): Shape { + val rad = LocalDensity.current.run { 8.dp.toPx() } + return remember(anchorPos) { + GenericShape { size, _ -> + val corners = CornerRadius(size.minDimension * .5f) + addRoundRect(RoundRect(size.toRect(), corners)) + if (size.width >= rad * 2.1f && size.height >= rad * 2.1f) { + val indicator = createIndicator(size, anchorPos, rad) + addPath(indicator) + } + } + } +} + +private fun createIndicator(shapeSize: Size, anchor: IntOffset, rad: Float): Path { + val x = anchor.x.toFloat().coerceIn(rad, shapeSize.width - rad) + val y = anchor.y.toFloat().coerceIn(rad, shapeSize.height - rad) + val (from, vec) = when { + anchor.y < 0 -> Offset(x, 0f) to Offset(0f, -1f) + anchor.y > shapeSize.height -> Offset(x, shapeSize.height) to Offset(0f, 1f) + anchor.x < 0 -> Offset(0f, y) to Offset(-1f, 0f) + anchor.x > shapeSize.width -> Offset(shapeSize.width, y) to Offset(1f, 0f) + else -> return Path() + } + val tan = Offset(-vec.y, vec.x) + return Path().apply { + (from + tan * rad).let { moveTo(it.x, it.y) } + (from + vec * (rad)).let { lineTo(it.x, it.y) } + (from - tan * rad).let { lineTo(it.x, it.y) } + close() + } +} + +@Composable +internal fun OptionsPopup( + onDismiss: () -> Unit, + optionCount: Int, + option: @Composable (Int) -> Unit, +) { + var anchorPos by remember { mutableStateOf(IntOffset.Zero) } + val isVertical = LocalVerticalControls.current + val side = if (isVertical) PopupSide.Start else PopupSide.Top + Popup( + onDismissRequest = onDismiss, + popupPositionProvider = rememberPopupPos(side = side) { anchorPos = it } + ) { + Surface( + shape = popupShape(anchorPos = anchorPos), + shadowElevation = 8.dp, + ) { + if (isVertical) LazyColumn { + items(optionCount) { i -> option(i) } + } else LazyRow { + items(optionCount) { i -> option(i) } + } + } + } +} \ No newline at end of file diff --git a/easycrop/src/main/java/com/mr0xf00/easycrop/utils/GestureState.kt b/easycrop/src/main/java/com/mr0xf00/easycrop/utils/GestureState.kt new file mode 100644 index 00000000..9fa359d9 --- /dev/null +++ b/easycrop/src/main/java/com/mr0xf00/easycrop/utils/GestureState.kt @@ -0,0 +1,168 @@ +package com.mr0xf00.easycrop.utils + +import androidx.compose.foundation.gestures.awaitFirstDown +import androidx.compose.foundation.gestures.detectTapGestures +import androidx.compose.foundation.gestures.detectTransformGestures +import androidx.compose.foundation.gestures.forEachGesture +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.rememberUpdatedState +import androidx.compose.ui.Modifier +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.input.pointer.* +import kotlinx.coroutines.coroutineScope +import kotlinx.coroutines.launch +import kotlin.math.max + +internal interface GestureState { + val zoom: ZoomState + val drag: DragState + val tap: TapState +} + +internal interface DragState { + fun onBegin(x: Float, y: Float) = Unit + fun onNext(dx: Float, dy: Float, x: Float, y: Float, pointers: Int) = Unit + fun onDone() = Unit +} + +internal inline fun DragState( + crossinline begin: (pos: Offset) -> Unit = { }, + crossinline done: () -> Unit = {}, + crossinline next: (delta: Offset, pos: Offset, pointers: Int) -> Unit = { _, _, _ -> }, +): DragState = object : DragState { + override fun onBegin(x: Float, y: Float) = begin(Offset(x, y)) + override fun onNext(dx: Float, dy: Float, x: Float, y: Float, pointers: Int) = + next(Offset(dx, dy), Offset(x, y), pointers) + + override fun onDone() = done() +} + +internal interface TapState { + fun onTap(x: Float, y: Float, pointers: Int) = Unit + fun onLongPress(x: Float, y: Float, pointers: Int) = Unit +} + +internal inline fun TapState( + crossinline longPress: (pos: Offset, pointers: Int) -> Unit = { _, _ -> }, + crossinline tap: (pos: Offset, pointers: Int) -> Unit = { _, _ -> }, +) = object : TapState { + override fun onTap(x: Float, y: Float, pointers: Int) = tap(Offset(x, y), pointers) + override fun onLongPress(x: Float, y: Float, pointers: Int) = longPress(Offset(x, y), pointers) +} + +internal interface ZoomState { + fun onBegin(cx: Float, cy: Float) = Unit + fun onNext(scale: Float, cx: Float, cy: Float) = Unit + fun onDone() = Unit +} + +internal inline fun ZoomState( + crossinline begin: (center: Offset) -> Unit = { }, + crossinline done: () -> Unit = {}, + crossinline next: (scale: Float, center: Offset) -> Unit = { _, _ -> }, +): ZoomState = object : ZoomState { + override fun onBegin(cx: Float, cy: Float) = begin(Offset(cx, cy)) + override fun onNext(scale: Float, cx: Float, cy: Float) = next(scale, Offset(cx, cy)) + override fun onDone() = done() +} + +@Composable +internal fun rememberGestureState( + zoom: ZoomState? = null, + drag: DragState? = null, + tap: TapState? = null, +): GestureState { + val zoomState by rememberUpdatedState(newValue = zoom ?: object : ZoomState {}) + val dragState by rememberUpdatedState(newValue = drag ?: object : DragState {}) + val tapState by rememberUpdatedState(newValue = tap ?: object : TapState {}) + return object : GestureState { + override val zoom: ZoomState get() = zoomState + override val drag: DragState get() = dragState + override val tap: TapState get() = tapState + } +} + +private data class GestureData( + var dragId: PointerId = PointerId(-1), + var firstPos: Offset = Offset.Unspecified, + var pos: Offset = Offset.Unspecified, + var nextPos: Offset = Offset.Unspecified, + var pointers: Int = 0, + var maxPointers: Int = 0, + var isDrag: Boolean = false, + var isZoom: Boolean = false, + var isTap: Boolean = false, +) + + +internal fun Modifier.onGestures(state: GestureState): Modifier { + return pointerInput(Unit) { + coroutineScope { + var info = GestureData() + launch { + detectTapGestures( + onLongPress = { state.tap.onLongPress(it.x, it.y, info.maxPointers) }, + onTap = { state.tap.onTap(it.x, it.y, info.maxPointers) }, + ) + } + launch { + detectTransformGestures(panZoomLock = true) { c, _, zoom, _ -> + if (!(info.isDrag || info.isZoom)) { + if (info.pointers == 1) { + state.drag.onBegin(info.firstPos.x, info.firstPos.y) + info.pos = info.firstPos + info.isDrag = true + } else if (info.pointers > 1) { + state.zoom.onBegin(c.x, c.y) + info.isZoom = true + } + } + if (info.isDrag) { + state.drag.onNext( + info.nextPos.x - info.pos.x, info.nextPos.y - info.pos.y, + info.nextPos.x, info.nextPos.y, info.pointers + ) + info.pos = info.nextPos + } else if (info.isZoom) { + if (zoom != 1f) state.zoom.onNext(zoom, c.x, c.y) + } + } + } + launch { + forEachGesture { + awaitPointerEventScope { + info = GestureData() + val first = awaitFirstDown(requireUnconsumed = false) + info.dragId = first.id + info.firstPos = first.position + info.pointers = 1 + info.maxPointers = 1 + var event: PointerEvent + while (info.pointers > 0) { + event = awaitPointerEvent(pass = PointerEventPass.Initial) + var dragPointer: PointerInputChange? = null + for (change in event.changes) { + if (change.changedToDown()) info.pointers++ + else if (change.changedToUp()) info.pointers-- + info.maxPointers = max(info.maxPointers, info.pointers) + if (change.id == info.dragId) dragPointer = change + } + if (dragPointer == null) dragPointer = + event.changes.firstOrNull { it.pressed } + if (dragPointer != null) { + info.nextPos = dragPointer.position + if (info.dragId != dragPointer.id) { + info.pos = info.nextPos + info.dragId = dragPointer.id + } + } + } + if (info.isDrag) state.drag.onDone() + if (info.isZoom) state.zoom.onDone() + } + } + } + } + } +} \ No newline at end of file diff --git a/easycrop/src/main/java/com/mr0xf00/easycrop/utils/Math.kt b/easycrop/src/main/java/com/mr0xf00/easycrop/utils/Math.kt new file mode 100644 index 00000000..10e2b17f --- /dev/null +++ b/easycrop/src/main/java/com/mr0xf00/easycrop/utils/Math.kt @@ -0,0 +1,29 @@ +package com.mr0xf00.easycrop.utils + +import androidx.compose.ui.geometry.Offset +import kotlin.math.* + +private const val Eps: Float = 2.4414062E-4f + +internal fun Float.eq0(): Boolean = abs(this) <= Eps +internal infix fun Float.eq(v: Float): Boolean = abs(v - this) <= Eps +internal fun Offset.eq(other: Offset) = x.eq(other.x) && y.eq(other.y) + +internal fun lerp(a: Float, b: Float, p: Float): Float = a + p * (b - a) +internal fun lerp(a: Int, b: Int, p: Float) = lerp(a.toFloat(), b.toFloat(), p).roundToInt() + +internal fun lerpAngle(a: Int, b: Int, p: Float): Int { + val diff = (((b - a + 180) % 360) - 180) + return (a + diff * p).roundToInt() +} + +internal fun Int.next90() = (this + 90).angleRange() +internal fun Int.prev90() = (this - 90).angleRange() +internal fun Int.angleRange(): Int { + val angle = (this % 360 + 360) % 360 + return if (angle <= 180) angle else angle - 360 +} + +fun Float.alignDown(alignment: Int): Float = floor(this / alignment) * alignment +fun Float.alignUp(alignment: Int): Float = ceil(this / alignment) * alignment +fun Float.align(alignment: Int): Float = round(this / alignment) * alignment diff --git a/easycrop/src/main/java/com/mr0xf00/easycrop/utils/Matrix.kt b/easycrop/src/main/java/com/mr0xf00/easycrop/utils/Matrix.kt new file mode 100644 index 00000000..a0fbb21e --- /dev/null +++ b/easycrop/src/main/java/com/mr0xf00/easycrop/utils/Matrix.kt @@ -0,0 +1,34 @@ +package com.mr0xf00.easycrop.utils + +import androidx.compose.ui.geometry.Rect +import androidx.compose.ui.graphics.Matrix + +internal val IdentityMat = Matrix() + +internal operator fun Matrix.times(other: Matrix): Matrix = copy().apply { + this *= other +} + +internal fun Matrix.setScaleTranslate(sx: Float, sy: Float, tx: Float, ty: Float) { + reset() + values[Matrix.ScaleX] = sx + values[Matrix.TranslateX] = tx + values[Matrix.ScaleY] = sy + values[Matrix.TranslateY] = ty +} + +internal fun Matrix.setRectToRect(src: Rect, dst: Rect) { + if (src.isEmpty || dst.isEmpty) { + setScaleTranslate(1f, 1f, 0f, 0f) + return + } + val sx: Float = dst.width / src.width + val tx = dst.left - src.left * sx + val sy: Float = dst.height / src.height + val ty = dst.top - src.top * sy + setScaleTranslate(sx, sy, tx, ty) +} + +internal fun Matrix.copy(): Matrix = Matrix(values.clone()) + +internal fun Matrix.inverted() = copy().apply { invert() } \ No newline at end of file diff --git a/easycrop/src/main/java/com/mr0xf00/easycrop/utils/PolygonPath.kt b/easycrop/src/main/java/com/mr0xf00/easycrop/utils/PolygonPath.kt new file mode 100644 index 00000000..97e84b50 --- /dev/null +++ b/easycrop/src/main/java/com/mr0xf00/easycrop/utils/PolygonPath.kt @@ -0,0 +1,16 @@ +package com.mr0xf00.easycrop.utils + +import androidx.compose.ui.graphics.Path + +fun polygonPath( + tx: Float = 0f, ty: Float = 0f, + sx: Float = 1f, sy: Float = 1f, + points: FloatArray +): Path = Path().apply { + if (points.size < 2) return@apply + moveTo(points[0] * sx + tx, points[1] * sy + ty) + for (i in 1 until points.size / 2) { + lineTo(points[(i * 2) + 0] * sx + tx, points[(i * 2) + 1] * sy + ty) + } + close() +} diff --git a/easycrop/src/main/java/com/mr0xf00/easycrop/utils/Rect.kt b/easycrop/src/main/java/com/mr0xf00/easycrop/utils/Rect.kt new file mode 100644 index 00000000..43a2067d --- /dev/null +++ b/easycrop/src/main/java/com/mr0xf00/easycrop/utils/Rect.kt @@ -0,0 +1,164 @@ +package com.mr0xf00.easycrop.utils + +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.geometry.Rect +import androidx.compose.ui.geometry.Size +import androidx.compose.ui.unit.IntOffset +import androidx.compose.ui.unit.IntRect +import androidx.compose.ui.unit.IntSize +import com.mr0xf00.easycrop.AspectRatio +import kotlin.math.* + +internal fun IntRect.toRect() = Rect( + left = left.toFloat(), top = top.toFloat(), + right = right.toFloat(), bottom = bottom.toFloat() +) + +internal fun Size.coerceAtMost(maxSize: Size?): Size = + if (maxSize == null) this else coerceAtMost(maxSize) + +internal fun Size.coerceAtMost(maxSize: Size): Size { + val scaleF = min(maxSize.width / width, maxSize.height / height) + if (scaleF >= 1f) return this + return Size(width = width * scaleF, height = height * scaleF) +} + +internal fun Rect.atOrigin(): Rect = Rect(offset = Offset.Zero, size = size) + +internal val Rect.area get() = width * height + +internal fun Rect.lerp(target: Rect, p: Float): Rect { + val tl0 = topLeft + val br0 = bottomRight + val dtl = target.topLeft - tl0 + val dbr = target.bottomRight - br0 + return Rect(tl0 + dtl * p, br0 + dbr * p) +} + +internal fun Rect.centerIn(outer: Rect): Rect = + translate(outer.center.x - center.x, outer.center.y - center.y) + +internal fun Rect.fitIn(outer: Rect): Rect { + val scaleF = min(outer.width / width, outer.height / height) + return scale(scaleF, scaleF) +} + +internal fun Rect.scale(sx: Float, sy: Float) = setSizeTL(width = width * sx, height = height * sy) + +internal fun Rect.setSizeTL(width: Float, height: Float) = + Rect(offset = topLeft, size = Size(width, height)) + +internal fun Rect.setSizeBR(width: Float, height: Float) = + Rect(bottom = bottom, right = right, left = right - width, top = bottom - height) + +internal fun Rect.setSizeCenter(width: Float, height: Float) = + Rect(offset = Offset(center.x - width / 2, center.y - height / 2), size = Size(width, height)) + +internal fun Rect.constrainResize(bounds: Rect): Rect = Rect( + left = left.coerceAtLeast(bounds.left), + top = top.coerceAtLeast(bounds.top), + right = right.coerceAtMost(bounds.right), + bottom = bottom.coerceAtMost(bounds.bottom), +) + +internal fun Rect.constrainOffset(bounds: Rect): Rect { + var (x, y) = topLeft + if (right > bounds.right) x += bounds.right - right + if (bottom > bounds.bottom) y += bounds.bottom - bottom + if (x < bounds.left) x += bounds.left - x + if (y < bounds.top) y += bounds.top - y + return Rect(Offset(x, y), size) +} + +internal fun IntRect.constrainOffset(bounds: IntRect): IntRect { + var (x, y) = topLeft + if (right > bounds.right) x += bounds.right - right + if (bottom > bounds.bottom) y += bounds.bottom - bottom + if (x < bounds.left) x += bounds.left - x + if (y < bounds.top) y += bounds.top - y + return IntRect(IntOffset(x, y), size) +} + +internal fun Rect.resize( + handle: Offset, + delta: Offset, +): Rect { + var (l, t, r, b) = this + val (dx, dy) = delta + if (handle.y == 1f) b += dy + else if (handle.y == 0f) t += dy + if (handle.x == 1f) r += dx + else if (handle.x == 0f) l += dx + if (l > r) l = r.also { r = l } + if (t > b) t = b.also { b = t } + return Rect(l, t, r, b) +} + +internal fun Rect.roundOut(): IntRect = IntRect( + left = floor(left).toInt(), top = floor(top).toInt(), + right = ceil(right).toInt(), bottom = ceil(bottom).toInt() +) + +internal fun Size.roundUp(): IntSize = IntSize(ceil(width).toInt(), ceil(height).toInt()) + +internal fun Rect.abs(rel: Offset): Offset { + return Offset(left + rel.x * width, top + rel.y * height) +} + +internal fun Rect.setAspect(aspect: AspectRatio): Rect = setAspect(aspect.x.toFloat() / aspect.y) + +internal fun Rect.setAspect(aspect: Float): Rect { + val dim = max(width, height) + return Rect(Offset.Zero, Size(dim * aspect, height = dim)) + .fitIn(this) + .centerIn(this) +} + +internal fun Size.keepAspect(old: Size): Size { + val a = width * height + return Size( + width = sqrt((a * old.width) / old.height), + height = sqrt((a * old.height) / old.width) + ) +} + +internal fun Rect.keepAspect(old: Rect): Rect { + return setSize(old, size.keepAspect(old.size)) +} + +internal fun Rect.setSize(old: Rect, size: Size): Rect { + var (l, t, r, b) = this + if ((old.left - l).absoluteValue < (old.right - r).absoluteValue) { + r = l + size.width + } else { + l = r - size.width + } + if ((old.top - t).absoluteValue < (old.bottom - b).absoluteValue) { + b = t + size.height + } else { + t = b - size.height + } + return Rect(l, t, r, b) +} + +internal fun Rect.scaleToFit(bounds: Rect, old: Rect): Rect { + val (l, t, r, b) = this + val scale = minOf( + (bounds.right - l) / (r - l), + (bounds.bottom - t) / (b - t), + (r - bounds.left) / (r - l), + (bottom - bounds.top) / (b - t), + ) + if (scale >= 1f) return this + return setSize(old, size * scale) +} + +internal fun IntRect.containsInclusive(other: IntRect): Boolean { + return other.left >= left && other.top >= top && + other.right <= right && other.bottom <= bottom +} + +internal fun Rect.align(alignment: Int): Rect = Rect( + left.alignDown(alignment), top.alignDown(alignment), + right.alignUp(alignment), bottom.alignUp(alignment) +) \ No newline at end of file diff --git a/easycrop/src/main/java/com/mr0xf00/easycrop/utils/ViewMat.kt b/easycrop/src/main/java/com/mr0xf00/easycrop/utils/ViewMat.kt new file mode 100644 index 00000000..1d8c7136 --- /dev/null +++ b/easycrop/src/main/java/com/mr0xf00/easycrop/utils/ViewMat.kt @@ -0,0 +1,83 @@ +package com.mr0xf00.easycrop.utils + +import androidx.compose.animation.core.animate +import androidx.compose.runtime.* +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.geometry.Rect +import androidx.compose.ui.graphics.Matrix +import kotlin.math.min + +@Stable +internal interface ViewMat { + fun zoomStart(center: Offset) + fun zoom(center: Offset, scale: Float) + suspend fun fit(inner: Rect, outer: Rect) + fun snapFit(inner: Rect, outer: Rect) + val matrix: Matrix + val invMatrix: Matrix + val scale: Float +} + +internal fun ViewMat() = object : ViewMat { + var c0 = Offset.Zero + var mat by mutableStateOf(Matrix(), neverEqualPolicy()) + val inv by derivedStateOf { + Matrix().apply { + setFrom(mat) + invert() + } + } + override val scale by derivedStateOf { + mat.values[Matrix.ScaleX] + } + + override fun zoomStart(center: Offset) { + c0 = center + } + + override fun zoom(center: Offset, scale: Float) { + val s = Matrix().apply { + translate(center.x - c0.x, center.y - c0.y) + translate(center.x, center.y) + scale(scale, scale) + translate(-center.x, -center.y) + } + update { it *= s } + c0 = center + } + + inline fun update(op: (Matrix) -> Unit) { + mat = mat.copy().also(op) + } + + override val matrix: Matrix + get() = mat + override val invMatrix: Matrix + get() = inv + + override suspend fun fit(inner: Rect, outer: Rect) { + val dst = getDst(inner, outer) ?: return + val mat = Matrix() + val initial = this.mat.copy() + animate(0f, 1f) { p, _ -> + update { + it.setFrom(initial) + it *= mat.apply { setRectToRect(inner, inner.lerp(dst, p)) } + } + } + } + + override fun snapFit(inner: Rect, outer: Rect) { + val dst = getDst(inner, outer) ?: return + update { it *= Matrix().apply { setRectToRect(inner, dst) } } + } + + private fun getDst(inner: Rect, outer: Rect): Rect? { + val scale = min(outer.width / inner.width, outer.height / inner.height) + return Rect(Offset.Zero, inner.size * scale).centerIn(outer) + } + + private fun Rect.similar(other: Rect): Boolean { + return (intersect(other).area / area) > .95f + } +} \ No newline at end of file diff --git a/easycrop/src/main/res/drawable/flip_hor.xml b/easycrop/src/main/res/drawable/flip_hor.xml new file mode 100644 index 00000000..6c5b2b8d --- /dev/null +++ b/easycrop/src/main/res/drawable/flip_hor.xml @@ -0,0 +1,5 @@ + + + diff --git a/easycrop/src/main/res/drawable/flip_ver.xml b/easycrop/src/main/res/drawable/flip_ver.xml new file mode 100644 index 00000000..5a47f853 --- /dev/null +++ b/easycrop/src/main/res/drawable/flip_ver.xml @@ -0,0 +1,9 @@ + + + diff --git a/easycrop/src/main/res/drawable/resize.xml b/easycrop/src/main/res/drawable/resize.xml new file mode 100644 index 00000000..1d2738cc --- /dev/null +++ b/easycrop/src/main/res/drawable/resize.xml @@ -0,0 +1,5 @@ + + + diff --git a/easycrop/src/main/res/drawable/restore.xml b/easycrop/src/main/res/drawable/restore.xml new file mode 100644 index 00000000..ad7bf639 --- /dev/null +++ b/easycrop/src/main/res/drawable/restore.xml @@ -0,0 +1,5 @@ + + + diff --git a/easycrop/src/main/res/drawable/rot_left.xml b/easycrop/src/main/res/drawable/rot_left.xml new file mode 100644 index 00000000..834b12f4 --- /dev/null +++ b/easycrop/src/main/res/drawable/rot_left.xml @@ -0,0 +1,8 @@ + + + + diff --git a/easycrop/src/main/res/drawable/rot_right.xml b/easycrop/src/main/res/drawable/rot_right.xml new file mode 100644 index 00000000..112c9eda --- /dev/null +++ b/easycrop/src/main/res/drawable/rot_right.xml @@ -0,0 +1,8 @@ + + + + diff --git a/easycrop/src/test/java/com/mr0xf00/easycrop/CropStateTest.kt b/easycrop/src/test/java/com/mr0xf00/easycrop/CropStateTest.kt new file mode 100644 index 00000000..56a13381 --- /dev/null +++ b/easycrop/src/test/java/com/mr0xf00/easycrop/CropStateTest.kt @@ -0,0 +1,75 @@ +package com.mr0xf00.easycrop + +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.geometry.Rect +import androidx.compose.ui.unit.IntSize +import androidx.compose.ui.unit.toIntRect +import com.mr0xf00.easycrop.images.DecodeParams +import com.mr0xf00.easycrop.images.DecodeResult +import com.mr0xf00.easycrop.images.ImageSrc +import com.mr0xf00.easycrop.utils.containsInclusive +import com.mr0xf00.easycrop.utils.resize +import com.mr0xf00.easycrop.utils.roundOut +import com.mr0xf00.easycrop.utils.scale +import org.junit.Assert +import org.junit.Before +import org.junit.Test + +class CropStateTest { + private val size = IntSize(500, 600) + private lateinit var state: CropState + + @Before + fun createState() { + state = CropState(emptyImage(size)) + } + + @Test + fun `Region is initialized to image rect`() { + Assert.assertEquals(size.toIntRect(), state.region.roundOut()) + } + + @Test + fun `Region is inside image rect after offset`() { + state.region = state.region.translate(100f, 100f) + assertRegionInImageRect() + } + + @Test + fun `Region is inside image rect after resize`() { + state.region = state.region + .scale(.5f, .5f) + .resize(Offset(1f, 1f), Offset(size.width * 2f, size.height * 2f)) + assertRegionInImageRect() + } + + @Test + fun `Region is inside image rect after resize with aspect lock`() { + state.aspectLock = true + state.region = state.region.transformCropRect() + assertRegionInImageRect() + } + + @Test + fun `Region is inside transformed image rect after resize`() { + state.rotLeft() + state.flipX() + state.region = state.region.transformCropRect() + assertRegionInImageRect() + } + + private fun Rect.transformCropRect(): Rect { + return scale(.5f, .5f) + .resize(Offset(1f, 1f), Offset(size.width * 2f, size.height * 2f)) + } + + private fun assertRegionInImageRect() { + val imgRect = getTransformedImageRect(state.transform, state.src.size) + Assert.assertTrue(imgRect.roundOut().containsInclusive(state.region.roundOut())) + } +} + +internal fun emptyImage(size: IntSize) = object : ImageSrc { + override val size: IntSize get() = size + override suspend fun open(params: DecodeParams): DecodeResult? = null +} \ No newline at end of file diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 741635ee..796836aa 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -10,7 +10,6 @@ jvmTarget = "17" accompanistPermissions = "0.36.0" agp = "8.6.0" datastorePreferences = "1.1.1" -easycrop = "0.1.1" easyvalidationCore = "1.0.4" firebaseBom = "33.7.0" firebaseMessagingKtx = "24.1.0" @@ -81,7 +80,6 @@ androidx-room-ktx = { module = "androidx.room:room-ktx", version.ref = "roomRunt androidx-room-runtime = { module = "androidx.room:room-runtime", version.ref = "roomRuntime" } androidx-runtime = { module = "androidx.compose.runtime:runtime", version.ref = "runtime" } compose-charts = { module = "io.github.ehsannarmani:compose-charts", version.ref = "composeCharts" } -easycrop = { module = "io.github.mr0xf00:easycrop", version.ref = "easycrop" } easyvalidation-core = { module = "com.wajahatkarim:easyvalidation-core", version.ref = "easyvalidationCore" } firebase-auth = { module = "com.google.firebase:firebase-auth-ktx" } firebase-crashlytics = { module = "com.google.firebase:firebase-crashlytics" } diff --git a/settings.gradle.kts b/settings.gradle.kts index 7f7f2285..cd60b030 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -25,6 +25,7 @@ rootProject.name = "QuickMem" enableFeaturePreview("TYPESAFE_PROJECT_ACCESSORS") include(":app") include(":compose-cardstack") +include(":easycrop") check(JavaVersion.current().isCompatibleWith(JavaVersion.VERSION_17)) { """ @@ -32,5 +33,4 @@ check(JavaVersion.current().isCompatibleWith(JavaVersion.VERSION_17)) { Java Home: [${System.getProperty("java.home")}] https://developer.android.com/build/jdks#jdk-config-in-studio """.trimIndent() -} - \ No newline at end of file +} \ No newline at end of file