mirror of
https://github.com/T8RIN/ImageToolbox.git
synced 2025-05-17 21:45:59 +08:00
apng tools wip
This commit is contained in:
@ -812,4 +812,11 @@
|
||||
<string name="linear_tilt_shift">Linear Tilt Shift</string>
|
||||
<string name="tags_to_remove">Tags To Remove</string>
|
||||
<string name="enhanced_zoom_blur">Enhanced Zoom Blur</string>
|
||||
<string name="apng_tools">APNG Tools</string>
|
||||
<string name="apng_tools_sub">Convert images to APNG picture or extract frames from given APNG image</string>
|
||||
<string name="apng_type_to_image">APNG to images</string>
|
||||
<string name="apng_type_to_image_sub">Convert APNG file to batch of pictures</string>
|
||||
<string name="apng_type_to_apng_sub">Convert batch of images to APNG file</string>
|
||||
<string name="apng_type_to_apng">Images to APNG</string>
|
||||
<string name="select_apng_image_to_start">Pick APNG image to start</string>
|
||||
</resources>
|
@ -42,6 +42,8 @@ import kotlinx.parcelize.IgnoredOnParcel
|
||||
import kotlinx.parcelize.Parcelize
|
||||
import kotlinx.parcelize.RawValue
|
||||
import ru.tech.imageresizershrinker.core.resources.R
|
||||
import ru.tech.imageresizershrinker.core.ui.icons.material.Apng
|
||||
import ru.tech.imageresizershrinker.core.ui.icons.material.ApngBox
|
||||
import ru.tech.imageresizershrinker.core.ui.icons.material.CropSmall
|
||||
import ru.tech.imageresizershrinker.core.ui.icons.material.Encrypted
|
||||
import ru.tech.imageresizershrinker.core.ui.icons.material.Exif
|
||||
@ -367,6 +369,49 @@ sealed class Screen(
|
||||
}
|
||||
}
|
||||
|
||||
data class ApngTools(
|
||||
val type: Type? = null
|
||||
) : Screen(
|
||||
id = 21,
|
||||
icon = Icons.Rounded.ApngBox,
|
||||
title = R.string.apng_tools,
|
||||
subtitle = R.string.apng_tools_sub
|
||||
) {
|
||||
@Parcelize
|
||||
sealed class Type(
|
||||
@StringRes val title: Int,
|
||||
@StringRes val subtitle: Int,
|
||||
@IgnoredOnParcel val icon: ImageVector? = null
|
||||
) : Parcelable {
|
||||
|
||||
data class ApngToImage(
|
||||
val apngUri: Uri? = null
|
||||
) : Type(
|
||||
title = R.string.apng_type_to_image,
|
||||
subtitle = R.string.apng_type_to_image_sub,
|
||||
icon = Icons.Outlined.Collections
|
||||
)
|
||||
|
||||
data class ImageToApng(
|
||||
val imageUris: List<Uri>? = null
|
||||
) : Type(
|
||||
title = R.string.apng_type_to_apng,
|
||||
subtitle = R.string.apng_type_to_apng_sub,
|
||||
icon = Icons.Rounded.Apng
|
||||
)
|
||||
|
||||
companion object {
|
||||
val entries by lazy {
|
||||
listOf(
|
||||
ApngToImage(),
|
||||
ImageToApng()
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
companion object {
|
||||
val typedEntries by lazy {
|
||||
listOf(
|
||||
@ -402,8 +447,9 @@ sealed class Screen(
|
||||
Compare(),
|
||||
GifTools(),
|
||||
ImagePreview(),
|
||||
LoadNetImage(),
|
||||
GeneratePalette(),
|
||||
ApngTools(),
|
||||
LoadNetImage(),
|
||||
) to Triple(
|
||||
R.string.tools,
|
||||
Icons.Rounded.Toolbox,
|
||||
@ -426,6 +472,7 @@ sealed class Screen(
|
||||
RecognizeText(),
|
||||
Watermarking(),
|
||||
GifTools(),
|
||||
ApngTools(),
|
||||
ImagePreview(),
|
||||
LoadNetImage(),
|
||||
PickColorFromImage(),
|
||||
@ -436,6 +483,6 @@ sealed class Screen(
|
||||
LimitResize()
|
||||
)
|
||||
}
|
||||
const val featuresCount = 27
|
||||
const val featuresCount = 29
|
||||
}
|
||||
}
|
1
feature/apng-tools/.gitignore
vendored
Normal file
1
feature/apng-tools/.gitignore
vendored
Normal file
@ -0,0 +1 @@
|
||||
/build
|
25
feature/apng-tools/build.gradle.kts
Normal file
25
feature/apng-tools/build.gradle.kts
Normal file
@ -0,0 +1,25 @@
|
||||
/*
|
||||
* ImageToolbox is an image editor for android
|
||||
* Copyright (c) 2024 T8RIN (Malik Mukhametzyanov)
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*
|
||||
* You should have received a copy of the Apache License
|
||||
* along with this program. If not, see <http://www.apache.org/licenses/LICENSE-2.0>.
|
||||
*/
|
||||
|
||||
plugins {
|
||||
alias(libs.plugins.image.toolbox.library)
|
||||
alias(libs.plugins.image.toolbox.feature)
|
||||
alias(libs.plugins.image.toolbox.hilt)
|
||||
alias(libs.plugins.image.toolbox.compose)
|
||||
}
|
||||
|
||||
android.namespace = "ru.tech.imageresizershrinker.feature.apng_tools"
|
20
feature/apng-tools/src/main/AndroidManifest.xml
Normal file
20
feature/apng-tools/src/main/AndroidManifest.xml
Normal file
@ -0,0 +1,20 @@
|
||||
<?xml version="1.0" encoding="utf-8"?><!--
|
||||
~ ImageToolbox is an image editor for android
|
||||
~ Copyright (c) 2024 T8RIN (Malik Mukhametzyanov)
|
||||
~
|
||||
~ Licensed under the Apache License, Version 2.0 (the "License");
|
||||
~ you may not use this file except in compliance with the License.
|
||||
~
|
||||
~ Unless required by applicable law or agreed to in writing, software
|
||||
~ distributed under the License is distributed on an "AS IS" BASIS,
|
||||
~ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
~ See the License for the specific language governing permissions and
|
||||
~ limitations under the License.
|
||||
~
|
||||
~ You should have received a copy of the Apache License
|
||||
~ along with this program. If not, see <http://www.apache.org/licenses/LICENSE-2.0>.
|
||||
-->
|
||||
|
||||
<manifest>
|
||||
|
||||
</manifest>
|
@ -0,0 +1,114 @@
|
||||
/*
|
||||
* ImageToolbox is an image editor for android
|
||||
* Copyright (c) 2024 T8RIN (Malik Mukhametzyanov)
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*
|
||||
* You should have received a copy of the Apache License
|
||||
* along with this program. If not, see <http://www.apache.org/licenses/LICENSE-2.0>.
|
||||
*/
|
||||
|
||||
package ru.tech.imageresizershrinker.feature.apng_tools.data
|
||||
|
||||
import android.content.Context
|
||||
import android.graphics.Bitmap
|
||||
import androidx.core.net.toUri
|
||||
import androidx.exifinterface.media.ExifInterface
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.cancel
|
||||
import kotlinx.coroutines.currentCoroutineContext
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.flow
|
||||
import kotlinx.coroutines.isActive
|
||||
import kotlinx.coroutines.withContext
|
||||
import oupson.apng.decoder.ApngDecoder
|
||||
import oupson.apng.encoder.ApngEncoder
|
||||
import ru.tech.imageresizershrinker.core.domain.image.ImageGetter
|
||||
import ru.tech.imageresizershrinker.core.domain.image.ShareProvider
|
||||
import ru.tech.imageresizershrinker.core.domain.model.ImageFormat
|
||||
import ru.tech.imageresizershrinker.core.domain.model.ImageInfo
|
||||
import ru.tech.imageresizershrinker.core.domain.model.IntegerSize
|
||||
import ru.tech.imageresizershrinker.core.domain.model.Quality
|
||||
import ru.tech.imageresizershrinker.feature.apng_tools.domain.ApngConverter
|
||||
import ru.tech.imageresizershrinker.feature.apng_tools.domain.ApngParams
|
||||
import java.io.ByteArrayOutputStream
|
||||
import javax.inject.Inject
|
||||
|
||||
|
||||
internal class AndroidApngConverter @Inject constructor(
|
||||
private val imageGetter: ImageGetter<Bitmap, ExifInterface>,
|
||||
private val imageShareProvider: ShareProvider<Bitmap>,
|
||||
@ApplicationContext private val context: Context
|
||||
) : ApngConverter {
|
||||
|
||||
override fun extractFramesFromApng(
|
||||
apngUri: String,
|
||||
imageFormat: ImageFormat,
|
||||
quality: Quality
|
||||
): Flow<String> = flow {
|
||||
ApngDecoder(
|
||||
context = context,
|
||||
uri = apngUri.toUri()
|
||||
).decodeAsync(currentCoroutineContext()) { frame ->
|
||||
if (!currentCoroutineContext().isActive) {
|
||||
currentCoroutineContext().cancel(null)
|
||||
return@decodeAsync
|
||||
}
|
||||
imageShareProvider.cacheImage(
|
||||
image = frame,
|
||||
imageInfo = ImageInfo(
|
||||
width = frame.width,
|
||||
height = frame.height,
|
||||
imageFormat = imageFormat,
|
||||
quality = quality
|
||||
),
|
||||
name = "apng_image"
|
||||
)?.let { emit(it) }
|
||||
|
||||
frame.recycle()
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun createApngFromImageUris(
|
||||
imageUris: List<String>,
|
||||
params: ApngParams,
|
||||
onProgress: () -> Unit
|
||||
): ByteArray = withContext(Dispatchers.IO) {
|
||||
val out = ByteArrayOutputStream()
|
||||
val size = params.size ?: imageGetter.getImage(data = imageUris[0])!!.run {
|
||||
IntegerSize(width, height)
|
||||
}
|
||||
|
||||
val encoder = ApngEncoder(
|
||||
outputStream = out,
|
||||
width = size.width,
|
||||
height = size.height,
|
||||
numberOfFrames = imageUris.size
|
||||
).apply {
|
||||
setRepetitionCount(params.repeatCount)
|
||||
setCompressionLevel(params.quality.qualityValue)
|
||||
}
|
||||
imageUris.forEach { uri ->
|
||||
imageGetter.getImage(
|
||||
data = uri,
|
||||
size = params.size
|
||||
)?.let {
|
||||
encoder.writeFrame(it, delay = params.delay.toFloat())
|
||||
}
|
||||
onProgress()
|
||||
}
|
||||
encoder.writeEnd()
|
||||
|
||||
out.toByteArray()
|
||||
}
|
||||
|
||||
|
||||
}
|
@ -0,0 +1,38 @@
|
||||
/*
|
||||
* ImageToolbox is an image editor for android
|
||||
* Copyright (c) 2024 T8RIN (Malik Mukhametzyanov)
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*
|
||||
* You should have received a copy of the Apache License
|
||||
* along with this program. If not, see <http://www.apache.org/licenses/LICENSE-2.0>.
|
||||
*/
|
||||
|
||||
package ru.tech.imageresizershrinker.feature.apng_tools.di
|
||||
|
||||
import dagger.Binds
|
||||
import dagger.Module
|
||||
import dagger.hilt.InstallIn
|
||||
import dagger.hilt.components.SingletonComponent
|
||||
import ru.tech.imageresizershrinker.feature.apng_tools.data.AndroidApngConverter
|
||||
import ru.tech.imageresizershrinker.feature.apng_tools.domain.ApngConverter
|
||||
import javax.inject.Singleton
|
||||
|
||||
@Module
|
||||
@InstallIn(SingletonComponent::class)
|
||||
internal interface GifToolsModule {
|
||||
|
||||
@Binds
|
||||
@Singleton
|
||||
fun provideConverter(
|
||||
converter: AndroidApngConverter
|
||||
): ApngConverter
|
||||
|
||||
}
|
@ -0,0 +1,38 @@
|
||||
/*
|
||||
* ImageToolbox is an image editor for android
|
||||
* Copyright (c) 2024 T8RIN (Malik Mukhametzyanov)
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*
|
||||
* You should have received a copy of the Apache License
|
||||
* along with this program. If not, see <http://www.apache.org/licenses/LICENSE-2.0>.
|
||||
*/
|
||||
|
||||
package ru.tech.imageresizershrinker.feature.apng_tools.domain
|
||||
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import ru.tech.imageresizershrinker.core.domain.model.ImageFormat
|
||||
import ru.tech.imageresizershrinker.core.domain.model.Quality
|
||||
|
||||
interface ApngConverter {
|
||||
|
||||
fun extractFramesFromApng(
|
||||
apngUri: String,
|
||||
imageFormat: ImageFormat,
|
||||
quality: Quality
|
||||
): Flow<String>
|
||||
|
||||
suspend fun createApngFromImageUris(
|
||||
imageUris: List<String>,
|
||||
params: ApngParams,
|
||||
onProgress: () -> Unit
|
||||
): ByteArray
|
||||
|
||||
}
|
@ -0,0 +1,39 @@
|
||||
/*
|
||||
* ImageToolbox is an image editor for android
|
||||
* Copyright (c) 2024 T8RIN (Malik Mukhametzyanov)
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*
|
||||
* You should have received a copy of the Apache License
|
||||
* along with this program. If not, see <http://www.apache.org/licenses/LICENSE-2.0>.
|
||||
*/
|
||||
|
||||
package ru.tech.imageresizershrinker.feature.apng_tools.domain
|
||||
|
||||
sealed interface ApngFrames {
|
||||
|
||||
data object All : ApngFrames {
|
||||
override fun getFramePositions(
|
||||
frameCount: Int
|
||||
): List<Int> = List(frameCount) { it + 1 }
|
||||
}
|
||||
|
||||
data class ManualSelection(
|
||||
val framePositions: List<Int>
|
||||
) : ApngFrames {
|
||||
override fun getFramePositions(
|
||||
frameCount: Int
|
||||
): List<Int> = framePositions.filter { it - 1 < frameCount }
|
||||
}
|
||||
|
||||
fun getFramePositions(
|
||||
frameCount: Int
|
||||
): List<Int>
|
||||
}
|
@ -0,0 +1,39 @@
|
||||
/*
|
||||
* ImageToolbox is an image editor for android
|
||||
* Copyright (c) 2024 T8RIN (Malik Mukhametzyanov)
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*
|
||||
* You should have received a copy of the Apache License
|
||||
* along with this program. If not, see <http://www.apache.org/licenses/LICENSE-2.0>.
|
||||
*/
|
||||
|
||||
package ru.tech.imageresizershrinker.feature.apng_tools.domain
|
||||
|
||||
import ru.tech.imageresizershrinker.core.domain.model.IntegerSize
|
||||
import ru.tech.imageresizershrinker.core.domain.model.Quality
|
||||
|
||||
data class ApngParams(
|
||||
val size: IntegerSize?,
|
||||
val repeatCount: Int,
|
||||
val delay: Int,
|
||||
val quality: Quality
|
||||
) {
|
||||
companion object {
|
||||
val Default by lazy {
|
||||
ApngParams(
|
||||
size = null,
|
||||
repeatCount = 1,
|
||||
delay = 1000,
|
||||
quality = Quality.Base(5)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
@ -0,0 +1,605 @@
|
||||
/*
|
||||
* ImageToolbox is an image editor for android
|
||||
* Copyright (c) 2024 T8RIN (Malik Mukhametzyanov)
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*
|
||||
* You should have received a copy of the Apache License
|
||||
* along with this program. If not, see <http://www.apache.org/licenses/LICENSE-2.0>.
|
||||
*/
|
||||
|
||||
package ru.tech.imageresizershrinker.feature.apng_tools.presentation
|
||||
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.content.res.Configuration
|
||||
import android.net.Uri
|
||||
import androidx.activity.ComponentActivity
|
||||
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.compose.animation.AnimatedContent
|
||||
import androidx.compose.animation.AnimatedVisibility
|
||||
import androidx.compose.animation.core.animateDpAsState
|
||||
import androidx.compose.animation.expandHorizontally
|
||||
import androidx.compose.animation.fadeIn
|
||||
import androidx.compose.animation.fadeOut
|
||||
import androidx.compose.animation.scaleIn
|
||||
import androidx.compose.animation.scaleOut
|
||||
import androidx.compose.animation.shrinkHorizontally
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.WindowInsets
|
||||
import androidx.compose.foundation.layout.asPaddingValues
|
||||
import androidx.compose.foundation.layout.calculateEndPadding
|
||||
import androidx.compose.foundation.layout.calculateStartPadding
|
||||
import androidx.compose.foundation.layout.displayCutout
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.outlined.FolderOff
|
||||
import androidx.compose.material.icons.outlined.PhotoSizeSelectLarge
|
||||
import androidx.compose.material.icons.outlined.RepeatOne
|
||||
import androidx.compose.material.icons.outlined.SelectAll
|
||||
import androidx.compose.material.icons.outlined.Share
|
||||
import androidx.compose.material.icons.outlined.Timelapse
|
||||
import androidx.compose.material.icons.rounded.Close
|
||||
import androidx.compose.material.icons.rounded.Save
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.LocalContentColor
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.surfaceColorAtElevation
|
||||
import androidx.compose.material3.windowsizeclass.WindowWidthSizeClass
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.derivedStateOf
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.saveable.rememberSaveable
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.platform.LocalConfiguration
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.platform.LocalLayoutDirection
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import dev.olshevski.navigation.reimagined.hilt.hiltViewModel
|
||||
import kotlinx.coroutines.launch
|
||||
import ru.tech.imageresizershrinker.core.domain.model.ImageFormat
|
||||
import ru.tech.imageresizershrinker.core.domain.model.ImageInfo
|
||||
import ru.tech.imageresizershrinker.core.domain.model.IntegerSize
|
||||
import ru.tech.imageresizershrinker.core.resources.R
|
||||
import ru.tech.imageresizershrinker.core.settings.presentation.LocalSettingsState
|
||||
import ru.tech.imageresizershrinker.core.ui.icons.material.Apng
|
||||
import ru.tech.imageresizershrinker.core.ui.utils.confetti.LocalConfettiController
|
||||
import ru.tech.imageresizershrinker.core.ui.utils.helper.ContextUtils.getFileName
|
||||
import ru.tech.imageresizershrinker.core.ui.utils.helper.Picker
|
||||
import ru.tech.imageresizershrinker.core.ui.utils.helper.ReviewHandler
|
||||
import ru.tech.imageresizershrinker.core.ui.utils.helper.failedToSaveImages
|
||||
import ru.tech.imageresizershrinker.core.ui.utils.helper.localImagePickerMode
|
||||
import ru.tech.imageresizershrinker.core.ui.utils.helper.rememberImagePicker
|
||||
import ru.tech.imageresizershrinker.core.ui.utils.navigation.Screen
|
||||
import ru.tech.imageresizershrinker.core.ui.widget.AdaptiveLayoutScreen
|
||||
import ru.tech.imageresizershrinker.core.ui.widget.buttons.BottomButtonsBlock
|
||||
import ru.tech.imageresizershrinker.core.ui.widget.buttons.EnhancedIconButton
|
||||
import ru.tech.imageresizershrinker.core.ui.widget.controls.EnhancedSliderItem
|
||||
import ru.tech.imageresizershrinker.core.ui.widget.controls.ImageFormatSelector
|
||||
import ru.tech.imageresizershrinker.core.ui.widget.controls.ImageReorderCarousel
|
||||
import ru.tech.imageresizershrinker.core.ui.widget.controls.QualityWidget
|
||||
import ru.tech.imageresizershrinker.core.ui.widget.controls.ResizeImageField
|
||||
import ru.tech.imageresizershrinker.core.ui.widget.dialogs.ExitWithoutSavingDialog
|
||||
import ru.tech.imageresizershrinker.core.ui.widget.modifier.container
|
||||
import ru.tech.imageresizershrinker.core.ui.widget.modifier.withModifier
|
||||
import ru.tech.imageresizershrinker.core.ui.widget.other.Loading
|
||||
import ru.tech.imageresizershrinker.core.ui.widget.other.LoadingDialog
|
||||
import ru.tech.imageresizershrinker.core.ui.widget.other.LocalToastHostState
|
||||
import ru.tech.imageresizershrinker.core.ui.widget.other.ToastDuration
|
||||
import ru.tech.imageresizershrinker.core.ui.widget.other.TopAppBarEmoji
|
||||
import ru.tech.imageresizershrinker.core.ui.widget.other.showError
|
||||
import ru.tech.imageresizershrinker.core.ui.widget.preferences.PreferenceItem
|
||||
import ru.tech.imageresizershrinker.core.ui.widget.preferences.PreferenceRowSwitch
|
||||
import ru.tech.imageresizershrinker.core.ui.widget.text.TopAppBarTitle
|
||||
import ru.tech.imageresizershrinker.core.ui.widget.utils.LocalWindowSizeClass
|
||||
import ru.tech.imageresizershrinker.feature.apng_tools.domain.ApngFrames
|
||||
import ru.tech.imageresizershrinker.feature.apng_tools.presentation.components.ApngConvertedImagesPreview
|
||||
import ru.tech.imageresizershrinker.feature.apng_tools.presentation.viewModel.ApngToolsViewModel
|
||||
import kotlin.math.roundToInt
|
||||
|
||||
@Composable
|
||||
fun ApngToolsScreen(
|
||||
typeState: Screen.ApngTools.Type?,
|
||||
onGoBack: () -> Unit,
|
||||
viewModel: ApngToolsViewModel = hiltViewModel()
|
||||
) {
|
||||
val context = LocalContext.current as ComponentActivity
|
||||
val toastHostState = LocalToastHostState.current
|
||||
|
||||
val scope = rememberCoroutineScope()
|
||||
val confettiController = LocalConfettiController.current
|
||||
val showConfetti: () -> Unit = {
|
||||
scope.launch {
|
||||
confettiController.showEmpty()
|
||||
}
|
||||
}
|
||||
|
||||
LaunchedEffect(typeState) {
|
||||
typeState?.let { viewModel.setType(it) }
|
||||
}
|
||||
|
||||
val pickImagesLauncher =
|
||||
rememberImagePicker(
|
||||
mode = localImagePickerMode(Picker.Multiple)
|
||||
) { list ->
|
||||
list.takeIf { it.isNotEmpty() }?.let(viewModel::setImageUris)
|
||||
}
|
||||
|
||||
val pickSingleImageLauncher = rememberImagePicker(
|
||||
mode = localImagePickerMode(Picker.Single)
|
||||
) { list ->
|
||||
list.takeIf { it.isNotEmpty() }?.firstOrNull()?.let {
|
||||
if (it.isApng(context)) {
|
||||
viewModel.setApngUri(it)
|
||||
} else {
|
||||
scope.launch {
|
||||
toastHostState.showToast(
|
||||
message = context.getString(R.string.select_apng_image_to_start),
|
||||
icon = Icons.Rounded.Apng
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val savePdfLauncher = rememberLauncherForActivityResult(
|
||||
contract = CreateDocument(),
|
||||
onResult = {
|
||||
it?.let { uri ->
|
||||
viewModel.saveApngTo(
|
||||
outputStream = context.contentResolver.openOutputStream(uri, "rw")
|
||||
) { t ->
|
||||
if (t != null) {
|
||||
scope.launch {
|
||||
toastHostState.showError(context, t)
|
||||
}
|
||||
} else {
|
||||
scope.launch {
|
||||
confettiController.showEmpty()
|
||||
}
|
||||
scope.launch {
|
||||
toastHostState.showToast(
|
||||
context.getString(
|
||||
R.string.saved_to_without_filename,
|
||||
""
|
||||
),
|
||||
Icons.Rounded.Save
|
||||
)
|
||||
ReviewHandler.showReview(context)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
var showExitDialog by rememberSaveable { mutableStateOf(false) }
|
||||
|
||||
val onBack = {
|
||||
if (viewModel.type != null) showExitDialog = true
|
||||
else onGoBack()
|
||||
}
|
||||
|
||||
val isPortrait =
|
||||
LocalConfiguration.current.orientation != Configuration.ORIENTATION_LANDSCAPE || LocalWindowSizeClass.current.widthSizeClass == WindowWidthSizeClass.Compact
|
||||
|
||||
AdaptiveLayoutScreen(
|
||||
title = {
|
||||
TopAppBarTitle(
|
||||
title = when (viewModel.type) {
|
||||
is Screen.ApngTools.Type.ApngToImage -> {
|
||||
stringResource(R.string.apng_type_to_image)
|
||||
}
|
||||
|
||||
is Screen.ApngTools.Type.ImageToApng -> {
|
||||
stringResource(R.string.apng_type_to_apng)
|
||||
}
|
||||
|
||||
null -> stringResource(R.string.apng_tools)
|
||||
},
|
||||
input = viewModel.type,
|
||||
isLoading = viewModel.isLoading,
|
||||
size = null
|
||||
)
|
||||
},
|
||||
onGoBack = onBack,
|
||||
topAppBarPersistentActions = {
|
||||
if (viewModel.type == null) TopAppBarEmoji()
|
||||
val pagesSize by remember(viewModel.apngFrames, viewModel.convertedImageUris) {
|
||||
derivedStateOf {
|
||||
viewModel.apngFrames.getFramePositions(viewModel.convertedImageUris.size).size
|
||||
}
|
||||
}
|
||||
val isApngToImage = viewModel.type is Screen.ApngTools.Type.ApngToImage
|
||||
AnimatedVisibility(
|
||||
visible = isApngToImage && pagesSize != viewModel.convertedImageUris.size,
|
||||
enter = fadeIn() + scaleIn() + expandHorizontally(),
|
||||
exit = fadeOut() + scaleOut() + shrinkHorizontally()
|
||||
) {
|
||||
EnhancedIconButton(
|
||||
containerColor = Color.Transparent,
|
||||
contentColor = LocalContentColor.current,
|
||||
enableAutoShadowAndBorder = false,
|
||||
onClick = viewModel::selectAllConvertedImages
|
||||
) {
|
||||
Icon(Icons.Outlined.SelectAll, null)
|
||||
}
|
||||
}
|
||||
AnimatedVisibility(
|
||||
modifier = Modifier
|
||||
.padding(8.dp)
|
||||
.container(
|
||||
shape = CircleShape,
|
||||
color = MaterialTheme.colorScheme.surfaceColorAtElevation(10.dp),
|
||||
resultPadding = 0.dp
|
||||
),
|
||||
visible = isApngToImage && pagesSize != 0
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.padding(start = 12.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.Center
|
||||
) {
|
||||
pagesSize.takeIf { it != 0 }?.let {
|
||||
Spacer(Modifier.width(8.dp))
|
||||
Text(
|
||||
text = it.toString(),
|
||||
fontSize = 20.sp,
|
||||
fontWeight = FontWeight.Medium
|
||||
)
|
||||
}
|
||||
EnhancedIconButton(
|
||||
containerColor = Color.Transparent,
|
||||
contentColor = LocalContentColor.current,
|
||||
enableAutoShadowAndBorder = false,
|
||||
onClick = viewModel::clearConvertedImagesSelection
|
||||
) {
|
||||
Icon(Icons.Rounded.Close, null)
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
actions = {
|
||||
EnhancedIconButton(
|
||||
containerColor = Color.Transparent,
|
||||
contentColor = LocalContentColor.current,
|
||||
enableAutoShadowAndBorder = false,
|
||||
onClick = {
|
||||
viewModel.performSharing(showConfetti)
|
||||
},
|
||||
enabled = !viewModel.isLoading && viewModel.type != null
|
||||
) {
|
||||
Icon(Icons.Outlined.Share, null)
|
||||
}
|
||||
},
|
||||
imagePreview = {
|
||||
AnimatedContent(
|
||||
targetState = viewModel.isLoading to viewModel.type
|
||||
) { (loading, type) ->
|
||||
Box(
|
||||
contentAlignment = Alignment.Center,
|
||||
modifier = if (loading) {
|
||||
Modifier.padding(32.dp)
|
||||
} else Modifier
|
||||
) {
|
||||
if (loading || type == null) {
|
||||
Loading()
|
||||
} else {
|
||||
when (type) {
|
||||
is Screen.ApngTools.Type.ApngToImage -> {
|
||||
ApngConvertedImagesPreview(
|
||||
imageUris = viewModel.convertedImageUris,
|
||||
apngFrames = viewModel.apngFrames,
|
||||
onApngFramesChange = viewModel::updateApngFrames,
|
||||
isPortrait = isPortrait,
|
||||
isLoadingApngImages = viewModel.isLoadingApngImages
|
||||
)
|
||||
}
|
||||
|
||||
is Screen.ApngTools.Type.ImageToApng -> Unit
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
placeImagePreview = viewModel.type is Screen.ApngTools.Type.ApngToImage,
|
||||
showImagePreviewAsStickyHeader = false,
|
||||
autoClearFocus = false,
|
||||
controls = {
|
||||
when (val type = viewModel.type) {
|
||||
is Screen.ApngTools.Type.ApngToImage -> {
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
ImageFormatSelector(
|
||||
value = viewModel.imageFormat,
|
||||
onValueChange = viewModel::setImageFormat
|
||||
)
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
QualityWidget(
|
||||
imageFormat = viewModel.imageFormat,
|
||||
enabled = true,
|
||||
quality = viewModel.params.quality,
|
||||
onQualityChange = viewModel::setQuality
|
||||
)
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
}
|
||||
|
||||
is Screen.ApngTools.Type.ImageToApng -> {
|
||||
val addImagesToPdfPicker = rememberImagePicker(
|
||||
mode = localImagePickerMode(Picker.Multiple)
|
||||
) { list ->
|
||||
list.takeIf { it.isNotEmpty() }?.let(viewModel::addImageToUris)
|
||||
}
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
ImageReorderCarousel(
|
||||
images = type.imageUris,
|
||||
onReorder = viewModel::reorderImageUris,
|
||||
onNeedToAddImage = { addImagesToPdfPicker.pickImage() },
|
||||
onNeedToRemoveImageAt = viewModel::removeImageAt
|
||||
)
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
val size = viewModel.params.size ?: IntegerSize.Undefined
|
||||
AnimatedVisibility(size.isDefined()) {
|
||||
ResizeImageField(
|
||||
imageInfo = ImageInfo(size.width, size.height),
|
||||
originalSize = null,
|
||||
onWidthChange = {
|
||||
viewModel.updateParams(
|
||||
viewModel.params.copy(
|
||||
size = size.copy(width = it)
|
||||
)
|
||||
)
|
||||
},
|
||||
onHeightChange = {
|
||||
viewModel.updateParams(
|
||||
viewModel.params.copy(
|
||||
size = size.copy(height = it)
|
||||
)
|
||||
)
|
||||
}
|
||||
)
|
||||
}
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
PreferenceRowSwitch(
|
||||
title = stringResource(id = R.string.use_size_of_first_frame),
|
||||
subtitle = stringResource(id = R.string.use_size_of_first_frame_sub),
|
||||
checked = viewModel.params.size == null,
|
||||
onClick = viewModel::setUseOriginalSize,
|
||||
startIcon = Icons.Outlined.PhotoSizeSelectLarge,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
color = Color.Unspecified,
|
||||
shape = RoundedCornerShape(24.dp)
|
||||
)
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
QualityWidget(
|
||||
imageFormat = ImageFormat.Jpeg,
|
||||
enabled = true,
|
||||
quality = viewModel.params.quality,
|
||||
onQualityChange = viewModel::setQuality
|
||||
)
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
EnhancedSliderItem(
|
||||
value = viewModel.params.repeatCount,
|
||||
icon = Icons.Outlined.RepeatOne,
|
||||
title = stringResource(id = R.string.repeat_count),
|
||||
valueRange = 1f..10f,
|
||||
steps = 9,
|
||||
internalStateTransformation = { it.roundToInt() },
|
||||
onValueChange = {
|
||||
viewModel.updateParams(
|
||||
viewModel.params.copy(
|
||||
repeatCount = it.roundToInt()
|
||||
)
|
||||
)
|
||||
},
|
||||
shape = RoundedCornerShape(24.dp)
|
||||
)
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
EnhancedSliderItem(
|
||||
value = viewModel.params.delay,
|
||||
icon = Icons.Outlined.Timelapse,
|
||||
title = stringResource(id = R.string.frame_delay),
|
||||
valueRange = 1f..4000f,
|
||||
internalStateTransformation = { it.roundToInt() },
|
||||
onValueChange = {
|
||||
viewModel.updateParams(
|
||||
viewModel.params.copy(
|
||||
delay = it.roundToInt()
|
||||
)
|
||||
)
|
||||
},
|
||||
shape = RoundedCornerShape(24.dp)
|
||||
)
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
}
|
||||
|
||||
null -> Unit
|
||||
}
|
||||
},
|
||||
contentPadding = animateDpAsState(
|
||||
if (viewModel.type == null) 12.dp
|
||||
else 20.dp
|
||||
).value,
|
||||
buttons = {
|
||||
val settingsState = LocalSettingsState.current
|
||||
BottomButtonsBlock(
|
||||
targetState = (viewModel.type == null) to isPortrait,
|
||||
onSecondaryButtonClick = {
|
||||
if (viewModel.type !is Screen.ApngTools.Type.ApngToImage) {
|
||||
pickImagesLauncher.pickImage()
|
||||
} else pickSingleImageLauncher.pickImage()
|
||||
},
|
||||
isPrimaryButtonVisible = viewModel.canSave,
|
||||
onPrimaryButtonClick = {
|
||||
viewModel.saveBitmaps(
|
||||
onApngSaveResult = { name ->
|
||||
runCatching {
|
||||
runCatching {
|
||||
savePdfLauncher.launch("image/apng#$name.png")
|
||||
}.onFailure {
|
||||
scope.launch {
|
||||
toastHostState.showToast(
|
||||
message = context.getString(R.string.activate_files),
|
||||
icon = Icons.Outlined.FolderOff,
|
||||
duration = ToastDuration.Long
|
||||
)
|
||||
}
|
||||
}
|
||||
}.onFailure {
|
||||
scope.launch {
|
||||
toastHostState.showToast(
|
||||
message = context.getString(R.string.activate_files),
|
||||
icon = Icons.Outlined.FolderOff,
|
||||
duration = ToastDuration.Long
|
||||
)
|
||||
}
|
||||
}
|
||||
},
|
||||
onResult = { results, savingPath ->
|
||||
context.failedToSaveImages(
|
||||
scope = scope,
|
||||
results = results,
|
||||
toastHostState = toastHostState,
|
||||
savingPathString = savingPath,
|
||||
isOverwritten = settingsState.overwriteFiles,
|
||||
showConfetti = showConfetti
|
||||
)
|
||||
}
|
||||
)
|
||||
},
|
||||
actions = {
|
||||
if (isPortrait) it()
|
||||
},
|
||||
showNullDataButtonAsContainer = true
|
||||
)
|
||||
},
|
||||
noDataControls = {
|
||||
val types = remember {
|
||||
listOf(
|
||||
Screen.ApngTools.Type.ImageToApng(),
|
||||
Screen.ApngTools.Type.ApngToImage()
|
||||
)
|
||||
}
|
||||
val preference1 = @Composable {
|
||||
PreferenceItem(
|
||||
title = stringResource(types[0].title),
|
||||
subtitle = stringResource(types[0].subtitle),
|
||||
startIcon = types[0].icon,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
color = MaterialTheme.colorScheme.surfaceColorAtElevation(1.dp),
|
||||
onClick = {
|
||||
pickImagesLauncher.pickImage()
|
||||
}
|
||||
)
|
||||
}
|
||||
val preference2 = @Composable {
|
||||
PreferenceItem(
|
||||
title = stringResource(types[1].title),
|
||||
subtitle = stringResource(types[1].subtitle),
|
||||
startIcon = types[1].icon,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
color = MaterialTheme.colorScheme.surfaceColorAtElevation(1.dp),
|
||||
onClick = {
|
||||
pickSingleImageLauncher.pickImage()
|
||||
}
|
||||
)
|
||||
}
|
||||
if (isPortrait) {
|
||||
Column {
|
||||
preference1()
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
preference2()
|
||||
}
|
||||
} else {
|
||||
val direction = LocalLayoutDirection.current
|
||||
Row(
|
||||
modifier = Modifier.padding(
|
||||
WindowInsets.displayCutout.asPaddingValues().let {
|
||||
PaddingValues(
|
||||
start = it.calculateStartPadding(direction),
|
||||
end = it.calculateEndPadding(direction)
|
||||
)
|
||||
}
|
||||
)
|
||||
) {
|
||||
preference1.withModifier(modifier = Modifier.weight(1f))
|
||||
Spacer(modifier = Modifier.width(8.dp))
|
||||
preference2.withModifier(modifier = Modifier.weight(1f))
|
||||
}
|
||||
}
|
||||
},
|
||||
isPortrait = isPortrait,
|
||||
canShowScreenData = viewModel.type != null
|
||||
)
|
||||
|
||||
if (viewModel.isSaving) {
|
||||
if (viewModel.left != -1) {
|
||||
LoadingDialog(
|
||||
done = viewModel.done,
|
||||
left = viewModel.left,
|
||||
onCancelLoading = viewModel::cancelSaving
|
||||
)
|
||||
} else {
|
||||
LoadingDialog(
|
||||
onCancelLoading = viewModel::cancelSaving
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
ExitWithoutSavingDialog(
|
||||
onExit = viewModel::clearAll,
|
||||
onDismiss = { showExitDialog = false },
|
||||
visible = showExitDialog
|
||||
)
|
||||
}
|
||||
|
||||
private fun Uri.isApng(context: Context): Boolean {
|
||||
return context.getFileName(this).toString().endsWith(".png")
|
||||
.or(context.contentResolver.getType(this)?.contains("png") == true)
|
||||
.or(context.contentResolver.getType(this)?.contains("apng") == true)
|
||||
}
|
||||
|
||||
private class CreateDocument : ActivityResultContracts.CreateDocument("*/*") {
|
||||
override fun createIntent(
|
||||
context: Context,
|
||||
input: String
|
||||
): Intent {
|
||||
return super.createIntent(
|
||||
context = context,
|
||||
input = input.split("#")[0]
|
||||
).putExtra(Intent.EXTRA_TITLE, input.split("#")[1])
|
||||
}
|
||||
}
|
||||
|
||||
private val ApngToolsViewModel.canSave: Boolean
|
||||
get() = (apngFrames == ApngFrames.All)
|
||||
.or(type is Screen.ApngTools.Type.ImageToApng)
|
||||
.or((apngFrames as? ApngFrames.ManualSelection)?.framePositions?.isNotEmpty() == true)
|
@ -0,0 +1,341 @@
|
||||
/*
|
||||
* ImageToolbox is an image editor for android
|
||||
* Copyright (c) 2024 T8RIN (Malik Mukhametzyanov)
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*
|
||||
* You should have received a copy of the Apache License
|
||||
* along with this program. If not, see <http://www.apache.org/licenses/LICENSE-2.0>.
|
||||
*/
|
||||
|
||||
package ru.tech.imageresizershrinker.feature.apng_tools.presentation.components
|
||||
|
||||
import androidx.compose.animation.AnimatedContent
|
||||
import androidx.compose.animation.AnimatedVisibility
|
||||
import androidx.compose.animation.core.animateDp
|
||||
import androidx.compose.animation.core.updateTransition
|
||||
import androidx.compose.animation.fadeIn
|
||||
import androidx.compose.animation.fadeOut
|
||||
import androidx.compose.animation.scaleIn
|
||||
import androidx.compose.animation.scaleOut
|
||||
import androidx.compose.animation.togetherWith
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.border
|
||||
import androidx.compose.foundation.gestures.scrollBy
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.aspectRatio
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.lazy.grid.GridCells
|
||||
import androidx.compose.foundation.lazy.grid.LazyHorizontalGrid
|
||||
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
|
||||
import androidx.compose.foundation.lazy.grid.itemsIndexed
|
||||
import androidx.compose.foundation.lazy.grid.rememberLazyGridState
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.CheckCircle
|
||||
import androidx.compose.material.icons.filled.RadioButtonUnchecked
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.MutableState
|
||||
import androidx.compose.runtime.derivedStateOf
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableFloatStateOf
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.RectangleShape
|
||||
import androidx.compose.ui.layout.layout
|
||||
import androidx.compose.ui.platform.LocalConfiguration
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
import androidx.compose.ui.platform.LocalHapticFeedback
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.isActive
|
||||
import ru.tech.imageresizershrinker.core.ui.widget.image.Picture
|
||||
import ru.tech.imageresizershrinker.core.ui.widget.modifier.dragHandler
|
||||
import ru.tech.imageresizershrinker.core.ui.widget.other.Loading
|
||||
import ru.tech.imageresizershrinker.feature.apng_tools.domain.ApngFrames
|
||||
|
||||
@Composable
|
||||
fun ApngConvertedImagesPreview(
|
||||
imageUris: List<String>,
|
||||
apngFrames: ApngFrames,
|
||||
onApngFramesChange: (ApngFrames) -> Unit,
|
||||
isPortrait: Boolean,
|
||||
isLoadingApngImages: Boolean,
|
||||
spacing: Dp = 8.dp
|
||||
) {
|
||||
val state = rememberLazyGridState()
|
||||
val autoScrollSpeed: MutableState<Float> = remember { mutableFloatStateOf(0f) }
|
||||
LaunchedEffect(autoScrollSpeed.value) {
|
||||
if (autoScrollSpeed.value != 0f) {
|
||||
while (isActive) {
|
||||
state.scrollBy(autoScrollSpeed.value)
|
||||
delay(10)
|
||||
}
|
||||
}
|
||||
}
|
||||
val getUris: () -> Set<Int> = {
|
||||
val indexes = apngFrames
|
||||
.getFramePositions(imageUris.size)
|
||||
.map { it - 1 }
|
||||
imageUris.mapIndexedNotNull { index, _ ->
|
||||
if (index in indexes) index + 1
|
||||
else null
|
||||
}.toSet()
|
||||
}
|
||||
val selectedItems by remember(imageUris, apngFrames) {
|
||||
mutableStateOf(getUris())
|
||||
}
|
||||
val privateSelectedItems = remember {
|
||||
mutableStateOf(selectedItems)
|
||||
}
|
||||
|
||||
LaunchedEffect(apngFrames, selectedItems) {
|
||||
if (apngFrames !is ApngFrames.ManualSelection || selectedItems.isEmpty()) {
|
||||
privateSelectedItems.value = selectedItems
|
||||
}
|
||||
}
|
||||
|
||||
val screenWidth = LocalConfiguration.current.screenWidthDp.dp
|
||||
val modifier = if (isPortrait) {
|
||||
Modifier.height(
|
||||
(130.dp * imageUris.size).coerceAtMost(420.dp)
|
||||
)
|
||||
} else {
|
||||
Modifier
|
||||
}.layout { measurable, constraints ->
|
||||
val result =
|
||||
measurable.measure(
|
||||
if (isPortrait) {
|
||||
constraints.copy(
|
||||
maxWidth = screenWidth.roundToPx()
|
||||
)
|
||||
} else {
|
||||
constraints.copy(
|
||||
maxHeight = constraints.maxHeight + 48.dp.roundToPx()
|
||||
)
|
||||
}
|
||||
)
|
||||
layout(result.measuredWidth, result.measuredHeight) {
|
||||
result.place(0, 0)
|
||||
}
|
||||
}
|
||||
|
||||
Box(modifier = modifier) {
|
||||
if (isPortrait) {
|
||||
LazyHorizontalGrid(
|
||||
rows = GridCells.Adaptive(120.dp),
|
||||
state = state,
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.dragHandler(
|
||||
key = null,
|
||||
lazyGridState = state,
|
||||
isVertical = false,
|
||||
haptics = LocalHapticFeedback.current,
|
||||
selectedItems = privateSelectedItems,
|
||||
onSelectionChange = {
|
||||
onApngFramesChange(ApngFrames.ManualSelection(it.toList()))
|
||||
},
|
||||
autoScrollSpeed = autoScrollSpeed,
|
||||
autoScrollThreshold = with(LocalDensity.current) { 40.dp.toPx() }
|
||||
),
|
||||
verticalArrangement = Arrangement.spacedBy(
|
||||
space = spacing,
|
||||
alignment = Alignment.CenterVertically
|
||||
),
|
||||
horizontalArrangement = Arrangement.spacedBy(
|
||||
space = spacing,
|
||||
alignment = Alignment.CenterHorizontally
|
||||
),
|
||||
contentPadding = PaddingValues(12.dp),
|
||||
) {
|
||||
itemsIndexed(
|
||||
items = imageUris,
|
||||
key = { index, uri -> "$uri-${index + 1}" }
|
||||
) { index, uri ->
|
||||
val selected by remember(index, privateSelectedItems.value) {
|
||||
derivedStateOf {
|
||||
index + 1 in privateSelectedItems.value
|
||||
}
|
||||
}
|
||||
ImageItem(
|
||||
selected = selected,
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.aspectRatio(1f),
|
||||
index = index,
|
||||
uri = uri
|
||||
)
|
||||
}
|
||||
item {
|
||||
AnimatedVisibility(isLoadingApngImages) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.aspectRatio(1f),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Loading()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
LazyVerticalGrid(
|
||||
columns = GridCells.Adaptive(90.dp),
|
||||
state = state,
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.dragHandler(
|
||||
key = null,
|
||||
lazyGridState = state,
|
||||
isVertical = true,
|
||||
haptics = LocalHapticFeedback.current,
|
||||
selectedItems = privateSelectedItems,
|
||||
onSelectionChange = {
|
||||
onApngFramesChange(ApngFrames.ManualSelection(it.toList()))
|
||||
},
|
||||
autoScrollSpeed = autoScrollSpeed,
|
||||
autoScrollThreshold = with(LocalDensity.current) { 40.dp.toPx() }
|
||||
),
|
||||
verticalArrangement = Arrangement.spacedBy(
|
||||
space = spacing,
|
||||
alignment = Alignment.CenterVertically
|
||||
),
|
||||
horizontalArrangement = Arrangement.spacedBy(
|
||||
space = spacing,
|
||||
alignment = Alignment.CenterHorizontally
|
||||
),
|
||||
contentPadding = PaddingValues(12.dp),
|
||||
) {
|
||||
itemsIndexed(
|
||||
items = imageUris,
|
||||
key = { index, uri -> "$uri-${index + 1}" }
|
||||
) { index, uri ->
|
||||
val selected by remember(index, privateSelectedItems.value) {
|
||||
derivedStateOf {
|
||||
index + 1 in privateSelectedItems.value
|
||||
}
|
||||
}
|
||||
ImageItem(
|
||||
selected = selected,
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.aspectRatio(1f),
|
||||
index = index,
|
||||
uri = uri
|
||||
)
|
||||
}
|
||||
item {
|
||||
AnimatedVisibility(isLoadingApngImages) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.aspectRatio(1f),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Loading()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ImageItem(
|
||||
modifier: Modifier,
|
||||
uri: String,
|
||||
index: Int,
|
||||
selected: Boolean
|
||||
) {
|
||||
val transition = updateTransition(selected)
|
||||
val padding by transition.animateDp { s ->
|
||||
if (s) 10.dp else 0.dp
|
||||
}
|
||||
val corners by transition.animateDp { s ->
|
||||
if (s) 16.dp else 0.dp
|
||||
}
|
||||
val bgColor = MaterialTheme.colorScheme.secondaryContainer
|
||||
|
||||
Box(
|
||||
modifier
|
||||
.clip(RoundedCornerShape(4.dp))
|
||||
.background(bgColor)
|
||||
) {
|
||||
Picture(
|
||||
modifier = Modifier
|
||||
.matchParentSize()
|
||||
.padding(padding)
|
||||
.clip(RoundedCornerShape(corners))
|
||||
.background(Color.White),
|
||||
shape = RectangleShape,
|
||||
model = uri
|
||||
)
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(padding)
|
||||
.clip(RoundedCornerShape(corners))
|
||||
.background(Color.Black.copy(0.3f)),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Text(
|
||||
text = (index + 1).toString(),
|
||||
color = Color.White,
|
||||
fontSize = 24.sp,
|
||||
fontWeight = FontWeight.Medium
|
||||
)
|
||||
}
|
||||
AnimatedContent(
|
||||
targetState = selected,
|
||||
transitionSpec = {
|
||||
fadeIn() + scaleIn() togetherWith fadeOut() + scaleOut()
|
||||
}
|
||||
) { selected ->
|
||||
if (selected) {
|
||||
Icon(
|
||||
imageVector = Icons.Filled.CheckCircle,
|
||||
tint = MaterialTheme.colorScheme.primary,
|
||||
contentDescription = null,
|
||||
modifier = Modifier
|
||||
.padding(4.dp)
|
||||
.border(2.dp, bgColor, CircleShape)
|
||||
.clip(CircleShape)
|
||||
.background(bgColor)
|
||||
)
|
||||
} else {
|
||||
Icon(
|
||||
imageVector = Icons.Filled.RadioButtonUnchecked,
|
||||
tint = Color.White.copy(alpha = 0.7f),
|
||||
contentDescription = null,
|
||||
modifier = Modifier.padding(6.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
@ -0,0 +1,388 @@
|
||||
/*
|
||||
* ImageToolbox is an image editor for android
|
||||
* Copyright (c) 2024 T8RIN (Malik Mukhametzyanov)
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*
|
||||
* You should have received a copy of the Apache License
|
||||
* along with this program. If not, see <http://www.apache.org/licenses/LICENSE-2.0>.
|
||||
*/
|
||||
|
||||
package ru.tech.imageresizershrinker.feature.apng_tools.presentation.viewModel
|
||||
|
||||
import android.graphics.Bitmap
|
||||
import android.net.Uri
|
||||
import androidx.compose.runtime.MutableState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableIntStateOf
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.exifinterface.media.ExifInterface
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.flow.onCompletion
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import ru.tech.imageresizershrinker.core.domain.image.ImageCompressor
|
||||
import ru.tech.imageresizershrinker.core.domain.image.ImageGetter
|
||||
import ru.tech.imageresizershrinker.core.domain.image.ShareProvider
|
||||
import ru.tech.imageresizershrinker.core.domain.model.ImageFormat
|
||||
import ru.tech.imageresizershrinker.core.domain.model.ImageInfo
|
||||
import ru.tech.imageresizershrinker.core.domain.model.IntegerSize
|
||||
import ru.tech.imageresizershrinker.core.domain.model.Quality
|
||||
import ru.tech.imageresizershrinker.core.domain.saving.FileController
|
||||
import ru.tech.imageresizershrinker.core.domain.saving.SaveResult
|
||||
import ru.tech.imageresizershrinker.core.domain.saving.model.ImageSaveTarget
|
||||
import ru.tech.imageresizershrinker.core.ui.utils.navigation.Screen
|
||||
import ru.tech.imageresizershrinker.core.ui.utils.state.update
|
||||
import ru.tech.imageresizershrinker.feature.apng_tools.domain.ApngConverter
|
||||
import ru.tech.imageresizershrinker.feature.apng_tools.domain.ApngFrames
|
||||
import ru.tech.imageresizershrinker.feature.apng_tools.domain.ApngParams
|
||||
import java.io.OutputStream
|
||||
import java.text.SimpleDateFormat
|
||||
import java.util.Date
|
||||
import java.util.Locale
|
||||
import javax.inject.Inject
|
||||
import kotlin.random.Random
|
||||
|
||||
@HiltViewModel
|
||||
class ApngToolsViewModel @Inject constructor(
|
||||
private val imageCompressor: ImageCompressor<Bitmap>,
|
||||
private val imageGetter: ImageGetter<Bitmap, ExifInterface>,
|
||||
private val fileController: FileController,
|
||||
private val apngConverter: ApngConverter,
|
||||
private val shareProvider: ShareProvider<Bitmap>
|
||||
) : ViewModel() {
|
||||
|
||||
private val _type: MutableState<Screen.ApngTools.Type?> = mutableStateOf(null)
|
||||
val type by _type
|
||||
|
||||
private val _isLoading: MutableState<Boolean> = mutableStateOf(false)
|
||||
val isLoading by _isLoading
|
||||
|
||||
private val _isLoadingApngImages: MutableState<Boolean> = mutableStateOf(false)
|
||||
val isLoadingApngImages by _isLoadingApngImages
|
||||
|
||||
private val _params: MutableState<ApngParams> = mutableStateOf(ApngParams.Default)
|
||||
val params by _params
|
||||
|
||||
private val _convertedImageUris: MutableState<List<String>> = mutableStateOf(emptyList())
|
||||
val convertedImageUris by _convertedImageUris
|
||||
|
||||
private val _imageFormat: MutableState<ImageFormat> = mutableStateOf(ImageFormat.Default())
|
||||
val imageFormat by _imageFormat
|
||||
|
||||
private val _apngFrames: MutableState<ApngFrames> = mutableStateOf(ApngFrames.All)
|
||||
val apngFrames by _apngFrames
|
||||
|
||||
private val _done: MutableState<Int> = mutableIntStateOf(0)
|
||||
val done by _done
|
||||
|
||||
private val _left: MutableState<Int> = mutableIntStateOf(-1)
|
||||
val left by _left
|
||||
|
||||
private val _isSaving: MutableState<Boolean> = mutableStateOf(false)
|
||||
val isSaving: Boolean by _isSaving
|
||||
|
||||
private var apngData: ByteArray? = null
|
||||
|
||||
fun setType(type: Screen.ApngTools.Type) {
|
||||
when (type) {
|
||||
is Screen.ApngTools.Type.ApngToImage -> {
|
||||
type.apngUri?.let { setApngUri(it) } ?: _type.update { null }
|
||||
}
|
||||
|
||||
is Screen.ApngTools.Type.ImageToApng -> {
|
||||
_type.update { type }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun setImageUris(uris: List<Uri>) {
|
||||
clearAll()
|
||||
_type.update {
|
||||
Screen.ApngTools.Type.ImageToApng(uris)
|
||||
}
|
||||
}
|
||||
|
||||
private var collectionJob: Job? = null
|
||||
fun setApngUri(uri: Uri) {
|
||||
clearAll()
|
||||
_type.update {
|
||||
Screen.ApngTools.Type.ApngToImage(uri)
|
||||
}
|
||||
updateApngFrames(ApngFrames.All)
|
||||
collectionJob?.cancel()
|
||||
collectionJob = viewModelScope.launch(Dispatchers.IO) {
|
||||
_isLoading.update { true }
|
||||
_isLoadingApngImages.update { true }
|
||||
apngConverter.extractFramesFromApng(
|
||||
apngUri = uri.toString(),
|
||||
imageFormat = imageFormat,
|
||||
quality = params.quality
|
||||
).onCompletion {
|
||||
_isLoading.update { false }
|
||||
_isLoadingApngImages.update { false }
|
||||
}.collect { nextUri ->
|
||||
if (isLoading) {
|
||||
_isLoading.update { false }
|
||||
}
|
||||
_convertedImageUris.update { it + nextUri }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun clearAll() {
|
||||
collectionJob?.cancel()
|
||||
collectionJob = null
|
||||
_type.update { null }
|
||||
_convertedImageUris.update { emptyList() }
|
||||
apngData = null
|
||||
savingJob?.cancel()
|
||||
savingJob = null
|
||||
_params.update { ApngParams.Default }
|
||||
}
|
||||
|
||||
fun updateApngFrames(apngFrames: ApngFrames) {
|
||||
_apngFrames.update { apngFrames }
|
||||
}
|
||||
|
||||
fun clearConvertedImagesSelection() = updateApngFrames(ApngFrames.ManualSelection(emptyList()))
|
||||
|
||||
fun selectAllConvertedImages() = updateApngFrames(ApngFrames.All)
|
||||
|
||||
private var savingJob: Job? = null
|
||||
|
||||
fun saveApngTo(
|
||||
outputStream: OutputStream?,
|
||||
onComplete: (Throwable?) -> Unit
|
||||
) {
|
||||
_isSaving.value = false
|
||||
savingJob?.cancel()
|
||||
savingJob = viewModelScope.launch {
|
||||
withContext(Dispatchers.IO) {
|
||||
_isSaving.value = true
|
||||
kotlin.runCatching {
|
||||
outputStream?.use {
|
||||
it.write(apngData)
|
||||
}
|
||||
}.exceptionOrNull().let(onComplete)
|
||||
_isSaving.value = false
|
||||
apngData = null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun saveBitmaps(
|
||||
onApngSaveResult: (String) -> Unit,
|
||||
onResult: (List<SaveResult>, String) -> Unit
|
||||
) {
|
||||
_isSaving.value = false
|
||||
savingJob?.cancel()
|
||||
savingJob = viewModelScope.launch(Dispatchers.IO) {
|
||||
_isSaving.value = true
|
||||
_left.value = 1
|
||||
_done.value = 0
|
||||
when (val type = _type.value) {
|
||||
is Screen.ApngTools.Type.ApngToImage -> {
|
||||
val results = mutableListOf<SaveResult>()
|
||||
type.apngUri?.toString()?.also { apngUri ->
|
||||
apngConverter.extractFramesFromApng(
|
||||
apngUri = apngUri,
|
||||
imageFormat = imageFormat,
|
||||
quality = params.quality,
|
||||
// onGetFramesCount = {
|
||||
// if (it == 0) {
|
||||
// _isSaving.value = false
|
||||
// savingJob?.cancel()
|
||||
// onResult(
|
||||
// listOf(SaveResult.Error.MissingPermissions), ""
|
||||
// )
|
||||
// }
|
||||
// _left.value = gifFrames.getFramePositions(it).size
|
||||
// }
|
||||
).onCompletion {
|
||||
onResult(results, fileController.savingPath)
|
||||
}.collect { uri ->
|
||||
imageGetter.getImage(
|
||||
data = uri,
|
||||
originalSize = true
|
||||
)?.let { localBitmap ->
|
||||
val imageInfo = ImageInfo(
|
||||
imageFormat = imageFormat,
|
||||
width = localBitmap.width,
|
||||
height = localBitmap.height
|
||||
)
|
||||
results.add(
|
||||
fileController.save(
|
||||
saveTarget = ImageSaveTarget<ExifInterface>(
|
||||
imageInfo = imageInfo,
|
||||
originalUri = uri,
|
||||
sequenceNumber = _done.value + 1,
|
||||
data = imageCompressor.compressAndTransform(
|
||||
image = localBitmap,
|
||||
imageInfo = ImageInfo(
|
||||
imageFormat = imageFormat,
|
||||
quality = params.quality,
|
||||
width = localBitmap.width,
|
||||
height = localBitmap.height
|
||||
)
|
||||
)
|
||||
),
|
||||
keepOriginalMetadata = false
|
||||
)
|
||||
)
|
||||
} ?: results.add(
|
||||
SaveResult.Error.Exception(Throwable())
|
||||
)
|
||||
_done.value++
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
is Screen.ApngTools.Type.ImageToApng -> {
|
||||
_left.value = type.imageUris?.size ?: -1
|
||||
apngData = type.imageUris?.map { it.toString() }?.let { list ->
|
||||
apngConverter.createApngFromImageUris(
|
||||
imageUris = list,
|
||||
params = params,
|
||||
onProgress = {
|
||||
_done.update { it + 1 }
|
||||
}
|
||||
).also {
|
||||
val timeStamp = SimpleDateFormat(
|
||||
"yyyy-MM-dd_HH-mm-ss",
|
||||
Locale.getDefault()
|
||||
).format(Date()) + "_${
|
||||
Random(Random.nextInt()).hashCode().toString().take(4)
|
||||
}"
|
||||
onApngSaveResult("APNG_$timeStamp")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
null -> Unit
|
||||
}
|
||||
_isSaving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
fun cancelSaving() {
|
||||
savingJob?.cancel()
|
||||
savingJob = null
|
||||
_isSaving.value = false
|
||||
}
|
||||
|
||||
fun reorderImageUris(uris: List<Uri>?) {
|
||||
if (type is Screen.ApngTools.Type.ImageToApng) {
|
||||
_type.update {
|
||||
Screen.ApngTools.Type.ImageToApng(uris)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun addImageToUris(uris: List<Uri>) {
|
||||
val type = _type.value
|
||||
if (type is Screen.ApngTools.Type.ImageToApng) {
|
||||
_type.update {
|
||||
val newUris = type.imageUris?.plus(uris)?.toSet()?.toList()
|
||||
|
||||
Screen.ApngTools.Type.ImageToApng(newUris)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun removeImageAt(index: Int) {
|
||||
val type = _type.value
|
||||
if (type is Screen.ApngTools.Type.ImageToApng) {
|
||||
_type.update {
|
||||
val newUris = type.imageUris?.toMutableList()?.apply {
|
||||
removeAt(index)
|
||||
}
|
||||
|
||||
Screen.ApngTools.Type.ImageToApng(newUris)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun setImageFormat(imageFormat: ImageFormat) {
|
||||
_imageFormat.update { imageFormat }
|
||||
}
|
||||
|
||||
fun setQuality(quality: Quality) {
|
||||
_params.update { it.copy(quality = quality) }
|
||||
}
|
||||
|
||||
fun updateParams(params: ApngParams) {
|
||||
_params.update { params }
|
||||
}
|
||||
|
||||
fun setUseOriginalSize(value: Boolean) {
|
||||
_params.update {
|
||||
it.copy(size = if (value) null else IntegerSize(1000, 1000))
|
||||
}
|
||||
}
|
||||
|
||||
fun performSharing(onComplete: () -> Unit) {
|
||||
_isSaving.value = false
|
||||
savingJob?.cancel()
|
||||
savingJob = viewModelScope.launch(Dispatchers.IO) {
|
||||
_isSaving.value = true
|
||||
_left.value = 1
|
||||
_done.value = 0
|
||||
when (val type = _type.value) {
|
||||
is Screen.ApngTools.Type.ApngToImage -> {
|
||||
_left.value = -1
|
||||
val positions =
|
||||
apngFrames.getFramePositions(convertedImageUris.size).map { it - 1 }
|
||||
val uris = convertedImageUris.filterIndexed { index, _ ->
|
||||
index in positions
|
||||
}
|
||||
shareProvider.shareImageUris(uris)
|
||||
onComplete()
|
||||
}
|
||||
|
||||
is Screen.ApngTools.Type.ImageToApng -> {
|
||||
_left.value = type.imageUris?.size ?: -1
|
||||
type.imageUris?.map { it.toString() }?.let { list ->
|
||||
apngConverter.createApngFromImageUris(
|
||||
imageUris = list,
|
||||
params = params,
|
||||
onProgress = {
|
||||
_done.update { it + 1 }
|
||||
}
|
||||
).also { byteArray ->
|
||||
val timeStamp = SimpleDateFormat(
|
||||
"yyyy-MM-dd_HH-mm-ss",
|
||||
Locale.getDefault()
|
||||
).format(Date()) + "_${
|
||||
Random(Random.nextInt()).hashCode().toString().take(4)
|
||||
}"
|
||||
val apngName = "APNG_$timeStamp"
|
||||
shareProvider.shareByteArray(
|
||||
byteArray = byteArray,
|
||||
filename = "$apngName.png",
|
||||
onComplete = onComplete
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
null -> {
|
||||
Unit
|
||||
}
|
||||
}
|
||||
_isSaving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
}
|
@ -46,4 +46,5 @@ dependencies {
|
||||
implementation(projects.feature.gradientMaker)
|
||||
implementation(projects.feature.watermarking)
|
||||
implementation(projects.feature.gifTools)
|
||||
implementation(projects.feature.apngTools)
|
||||
}
|
@ -39,6 +39,7 @@ import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.launch
|
||||
import ru.tech.imageresizershrinker.core.settings.presentation.LocalSettingsState
|
||||
import ru.tech.imageresizershrinker.core.ui.utils.navigation.Screen
|
||||
import ru.tech.imageresizershrinker.feature.apng_tools.presentation.ApngToolsScreen
|
||||
import ru.tech.imageresizershrinker.feature.bytes_resize.presentation.BytesResizeScreen
|
||||
import ru.tech.imageresizershrinker.feature.cipher.presentation.FileCipherScreen
|
||||
import ru.tech.imageresizershrinker.feature.compare.presentation.CompareScreen
|
||||
@ -270,6 +271,13 @@ fun ScreenSelector(
|
||||
onGoBack = onGoBack
|
||||
)
|
||||
}
|
||||
|
||||
is Screen.ApngTools -> {
|
||||
ApngToolsScreen(
|
||||
typeState = screen.type,
|
||||
onGoBack = onGoBack
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
val currentScreen by remember(navController.backstack.entries) {
|
||||
|
@ -63,6 +63,7 @@ include(":feature:recognize-text")
|
||||
include(":feature:watermarking")
|
||||
include(":feature:gradient-maker")
|
||||
include(":feature:gif-tools")
|
||||
include(":feature:apng-tools")
|
||||
|
||||
include(":core:settings")
|
||||
include(":core:resources")
|
||||
|
Reference in New Issue
Block a user