diff --git a/app/build.gradle b/app/build.gradle index 0e0c11e..cb8a36a 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -18,7 +18,7 @@ android { applicationId "space.taran.arkretouch2" minSdk 26 - targetSdk 32 + targetSdk 33 versionCode 1 versionName "1.0" setProperty("archivesBaseName", "ark-retouch") diff --git a/app/src/main/java/space/taran/arkretouch/presentation/edit/EditScreen.kt b/app/src/main/java/space/taran/arkretouch/presentation/edit/EditScreen.kt index b211bf5..7d871c6 100644 --- a/app/src/main/java/space/taran/arkretouch/presentation/edit/EditScreen.kt +++ b/app/src/main/java/space/taran/arkretouch/presentation/edit/EditScreen.kt @@ -350,6 +350,9 @@ private fun BoxScope.TopMenu( onPositiveClick = { savePath -> viewModel.saveImage(savePath) viewModel.showSavePathDialog = false + }, + onCompressFormatChanged = { + viewModel.compressionFormat = it } ) if (viewModel.showMoreOptionsPopup) diff --git a/app/src/main/java/space/taran/arkretouch/presentation/edit/EditViewModel.kt b/app/src/main/java/space/taran/arkretouch/presentation/edit/EditViewModel.kt index b77348f..93b7546 100644 --- a/app/src/main/java/space/taran/arkretouch/presentation/edit/EditViewModel.kt +++ b/app/src/main/java/space/taran/arkretouch/presentation/edit/EditViewModel.kt @@ -3,6 +3,7 @@ package space.taran.arkretouch.presentation.edit import android.content.Context import android.content.Intent import android.graphics.Bitmap +import android.graphics.Bitmap.CompressFormat import android.graphics.Matrix import android.graphics.drawable.Drawable import android.net.Uri @@ -47,6 +48,8 @@ import space.taran.arkretouch.presentation.edit.resize.ResizeOperation import timber.log.Timber import java.io.File import java.nio.file.Path +import kotlin.io.path.Path +import kotlin.io.path.extension import kotlin.io.path.outputStream import kotlin.system.measureTimeMillis @@ -72,6 +75,7 @@ class EditViewModel( var showEyeDropperHint by mutableStateOf(false) val showConfirmClearDialog = mutableStateOf(false) var isLoaded by mutableStateOf(false) + var compressionFormat by mutableStateOf(CompressFormat.PNG) var exitConfirmed = false private set val bottomButtonsScrollIsAtStart = mutableStateOf(true) @@ -113,6 +117,7 @@ class EditViewModel( imagePath, editManager ) + extractCompressionFormat(it.extension) return } imageUri?.let { @@ -121,6 +126,9 @@ class EditViewModel( imageUri, editManager ) + Uri.parse(it)?.path?.let { path -> + extractCompressionFormat(Path(path).extension) + } return } editManager.scaleToFit() @@ -133,7 +141,7 @@ class EditViewModel( savePath.outputStream().use { out -> combinedBitmap.asAndroidBitmap() - .compress(Bitmap.CompressFormat.PNG, 100, out) + .compress(compressionFormat, 100, out) } imageSaved = true isSavingImage = false @@ -392,6 +400,15 @@ class EditViewModel( } } + private fun extractCompressionFormat(extension: String) { + compressionFormat = when (extension) { + ImageExtensions.PNG -> CompressFormat.PNG + ImageExtensions.JPEG, ImageExtensions.JPG -> CompressFormat.JPEG + ImageExtensions.WEBP -> CompressFormat.WEBP + else -> CompressFormat.PNG + } + } + companion object { private const val KEEP_USED_COLORS = 20 } diff --git a/app/src/main/java/space/taran/arkretouch/presentation/edit/SavePathDialog.kt b/app/src/main/java/space/taran/arkretouch/presentation/edit/SavePathDialog.kt index 2f1add6..6060368 100644 --- a/app/src/main/java/space/taran/arkretouch/presentation/edit/SavePathDialog.kt +++ b/app/src/main/java/space/taran/arkretouch/presentation/edit/SavePathDialog.kt @@ -1,5 +1,7 @@ package space.taran.arkretouch.presentation.edit +import android.graphics.Bitmap.CompressFormat +import android.os.Build import androidx.compose.foundation.background import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement @@ -11,6 +13,7 @@ import androidx.compose.foundation.layout.wrapContentHeight import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.wrapContentSize import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.Button import androidx.compose.material.Checkbox @@ -42,18 +45,27 @@ import space.taran.arkretouch.R import space.taran.arkretouch.presentation.utils.findNotExistCopyName import kotlin.io.path.name import androidx.compose.material.CircularProgressIndicator +import androidx.compose.material.Icon +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.KeyboardArrowDown +import androidx.compose.material.icons.filled.KeyboardArrowUp import androidx.compose.runtime.key -import androidx.compose.ui.ExperimentalComposeUiApi +import androidx.compose.ui.unit.IntOffset +import androidx.compose.ui.window.Popup +import androidx.compose.ui.window.PopupProperties +import space.taran.arkretouch.presentation.picker.toPx import java.nio.file.Files +import java.util.Locale +import kotlin.io.path.extension import kotlin.streams.toList -@OptIn(ExperimentalComposeUiApi::class) @Composable fun SavePathDialog( initialImagePath: Path?, fragmentManager: FragmentManager, onDismissClick: () -> Unit, - onPositiveClick: (Path) -> Unit + onPositiveClick: (Path) -> Unit, + onCompressFormatChanged: (CompressFormat) -> Unit ) { var currentPath by remember { mutableStateOf(initialImagePath?.parent) } var imagePath by remember { mutableStateOf(initialImagePath) } @@ -66,9 +78,49 @@ fun SavePathDialog( } ?: "image.png" ) } + var compressionFormat by remember { + var format = initialImagePath?.let { + when (it.extension) { + ImageExtensions.PNG, + ImageExtensions.JPEG, + ImageExtensions.WEBP -> it.extension + ImageExtensions.JPG -> ImageExtensions.JPEG + else -> ImageExtensions.PNG + } + } ?: ImageExtensions.PNG + if (format == ImageExtensions.WEBP) { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) + format = ImageExtensions.Webp.WEBP_LOSSLESS + } + mutableStateOf(format.uppercase(Locale.getDefault())) + } + var showCompressionFormats by remember { mutableStateOf(false) } val lifecycleOwner = LocalLifecycleOwner.current + fun updateImagePath(imageName: String) { + var extension = + compressionFormat.lowercase(Locale.getDefault()) + if ( + extension == ImageExtensions.Webp.WEBP_LOSSLESS || + extension == ImageExtensions.Webp.WEBP_LOSSY + ) extension = ImageExtensions.WEBP + + name = "$imageName.$extension" + + currentPath?.let { path -> + imagePath = path.resolve(name) + showOverwriteCheckbox.value = + Files.list(path).toList() + .contains(imagePath) + if (showOverwriteCheckbox.value) { + name = path.findNotExistCopyName( + imagePath?.fileName!! + ).name + } + } + } + LaunchedEffect(overwriteOriginalPath) { if (overwriteOriginalPath) { imagePath?.let { @@ -123,25 +175,42 @@ fun SavePathDialog( ?: stringResource(R.string.pick_folder) ) } - OutlinedTextField( - modifier = Modifier.padding(5.dp), - value = name, - onValueChange = { - name = it - currentPath?.let { path -> - imagePath = path.resolve(name) - showOverwriteCheckbox.value = Files.list(path).toList() - .contains(imagePath) - if (showOverwriteCheckbox.value) { - name = path.findNotExistCopyName( - imagePath?.fileName!! - ).name - } - } - }, - label = { Text(text = stringResource(R.string.name)) }, - singleLine = true - ) + Row( + Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically + ) { + OutlinedTextField( + modifier = Modifier + .fillMaxWidth(0.8f) + .padding(5.dp), + value = name.substringBeforeLast( + ImageExtensions.Delimeters.PERIOD + ), + onValueChange = { + updateImagePath(it) + }, + label = { Text(text = stringResource(R.string.name)) }, + singleLine = true + ) + Column( + Modifier + .wrapContentHeight() + .fillMaxWidth() + .clickable { + showCompressionFormats = !showCompressionFormats + }, + horizontalAlignment = Alignment.CenterHorizontally + ) { + Icon( + if (showCompressionFormats) + Icons.Filled.KeyboardArrowDown + else Icons.Filled.KeyboardArrowUp, + null, + Modifier.size(32.dp) + ) + Text(compressionFormat, maxLines = 1) + } + } if (showOverwriteCheckbox.value) { Row( modifier = Modifier @@ -175,7 +244,7 @@ fun SavePathDialog( Button( modifier = Modifier.padding(5.dp), onClick = { - if (currentPath != null && name != null) + if (currentPath != null) onPositiveClick(currentPath!!.resolve(name)) } ) { @@ -183,6 +252,21 @@ fun SavePathDialog( } } } + if (showCompressionFormats) { + CompressionFormats( + { formatName, format -> + compressionFormat = formatName + updateImagePath( + name.substringBeforeLast( + ImageExtensions.Delimeters.PERIOD + ) + ) + onCompressFormatChanged(format) + showCompressionFormats = false + }, + { showCompressionFormats = false } + ) + } } } } @@ -202,9 +286,96 @@ fun SaveProgress() { } } +@Composable +fun CompressionFormats( + onFormatClick: (String, CompressFormat) -> Unit, + onDismiss: () -> Unit +) { + Popup( + alignment = Alignment.TopEnd, + offset = IntOffset( + -5.dp.toPx().toInt(), + -10.dp.toPx().toInt() + ), + onDismissRequest = onDismiss, + properties = PopupProperties(focusable = true) + ) { + Column( + Modifier + .wrapContentSize() + .background(Color.LightGray, RoundedCornerShape(5)), + horizontalAlignment = Alignment.CenterHorizontally + ) { + val png = stringResource(R.string.png) + val jpeg = stringResource(R.string.jpeg) + val webpLossless = stringResource(R.string.webp_lossless) + val webpLossy = stringResource(R.string.webp_lossy) + val webp = stringResource(R.string.webp) + Text( + png, + Modifier + .padding(8.dp) + .clickable { + onFormatClick(png, CompressFormat.PNG) + } + ) + Text( + jpeg, + Modifier + .padding(8.dp) + .clickable { + onFormatClick(jpeg, CompressFormat.JPEG) + } + ) + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { + Text( + webpLossless, + Modifier + .padding(8.dp) + .clickable { + onFormatClick(webpLossless, CompressFormat.WEBP_LOSSLESS) + } + ) + Text( + webpLossy, + Modifier + .padding(8.dp) + .clickable { + onFormatClick(webpLossy, CompressFormat.WEBP_LOSSY) + } + ) + } else { + Text( + webp, + Modifier + .padding(8.dp) + .clickable { + onFormatClick(webp, CompressFormat.WEBP) + } + ) + } + } + } +} + fun folderFilePickerConfig(initialPath: Path?) = ArkFilePickerConfig( mode = ArkFilePickerMode.FOLDER, initialPath = initialPath, showRoots = true, rootsFirstPage = true ) + +object ImageExtensions { + const val PNG = "png" + const val JPEG = "jpeg" + const val JPG = "jpg" + const val WEBP = "webp" + object Webp { + const val WEBP_LOSSLESS = "webp_lossless" + const val WEBP_LOSSY = "webp_lossy" + } + + object Delimeters { + const val PERIOD = '.' + } +} diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 6254c1c..f167218 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -34,4 +34,9 @@ Width cannot be %s Please enter width Please enter height + WEBP_LOSSY + WEBP_LOSSLESS + JPEG + PNG + WEBP