chore: import upstream snapshot with attribution

This commit is contained in:
wehub-resource-sync
2026-07-13 12:58:31 +08:00
commit f06de36198
4585 changed files with 588418 additions and 0 deletions
+1
View File
@@ -0,0 +1 @@
/build
+30
View File
@@ -0,0 +1,30 @@
/*
* ImageToolbox is an image editor for android
* Copyright (c) 2026 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 = "com.t8rin.imagetoolbox.feature.gradient_maker"
dependencies {
implementation(projects.feature.compare)
implementation(projects.feature.pickColor)
}
@@ -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,403 @@
/*
* 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 com.t8rin.imagetoolbox.feature.gradient_maker.data
import android.graphics.Bitmap
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.geometry.Rect
import androidx.compose.ui.geometry.Size
import androidx.compose.ui.graphics.BlendMode
import androidx.compose.ui.graphics.Canvas
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.Paint
import androidx.compose.ui.graphics.Path
import androidx.compose.ui.graphics.PathMeasure
import androidx.compose.ui.graphics.ShaderBrush
import androidx.compose.ui.graphics.TileMode
import androidx.compose.ui.graphics.VertexMode
import androidx.compose.ui.graphics.Vertices
import androidx.compose.ui.graphics.asImageBitmap
import androidx.compose.ui.graphics.drawscope.CanvasDrawScope
import androidx.compose.ui.graphics.drawscope.scale
import androidx.compose.ui.graphics.lerp
import androidx.core.graphics.createBitmap
import com.t8rin.imagetoolbox.core.data.utils.safeConfig
import com.t8rin.imagetoolbox.core.domain.coroutines.DispatchersHolder
import com.t8rin.imagetoolbox.core.domain.model.IntegerSize
import com.t8rin.imagetoolbox.feature.gradient_maker.domain.GradientMaker
import com.t8rin.imagetoolbox.feature.gradient_maker.domain.GradientState
import com.t8rin.imagetoolbox.feature.gradient_maker.domain.MeshGradientState
import kotlinx.coroutines.withContext
import javax.inject.Inject
internal class AndroidGradientMaker @Inject constructor(
dispatchersHolder: DispatchersHolder
) : DispatchersHolder by dispatchersHolder,
GradientMaker<Bitmap, ShaderBrush, Size, Color, TileMode, Offset> {
override suspend fun createGradient(
integerSize: IntegerSize,
gradientState: GradientState<ShaderBrush, Size, Color, TileMode, Offset>
): Bitmap? = createGradient(
src = integerSize.toSize().run {
createBitmap(
width = width.toInt(),
height = height.toInt()
)
},
gradientState = gradientState,
gradientAlpha = 1f
)
override suspend fun createGradient(
src: Bitmap,
gradientState: GradientState<ShaderBrush, Size, Color, TileMode, Offset>,
gradientAlpha: Float
): Bitmap? = withContext(defaultDispatcher) {
val size = IntegerSize(
src.width,
src.height
).toSize()
gradientState.getBrush(size)?.let { brush ->
src.copy(src.safeConfig, true).apply {
setHasAlpha(true)
Canvas(asImageBitmap()).apply {
drawImage(asImageBitmap(), Offset.Zero, Paint())
drawRect(
paint = Paint().apply {
shader = brush.createShader(size)
alpha = gradientAlpha
},
rect = Rect(offset = Offset.Zero, size = size)
)
}
}
}
}
override suspend fun createMeshGradient(
integerSize: IntegerSize,
gradientState: MeshGradientState<Color, Offset>
): Bitmap? = createMeshGradient(
src = integerSize.toSize().run {
createBitmap(
width = width.toInt(),
height = height.toInt()
)
},
gradientState = gradientState,
gradientAlpha = 1f
)
override suspend fun createMeshGradient(
src: Bitmap,
gradientState: MeshGradientState<Color, Offset>,
gradientAlpha: Float
): Bitmap? = withContext(defaultDispatcher) {
src.copy(src.safeConfig, true).apply {
setHasAlpha(true)
val paint = Paint().apply {
alpha = gradientAlpha
}
Canvas(asImageBitmap()).apply {
drawImage(asImageBitmap(), Offset.Zero, Paint())
drawMeshGradient(
pointData = PointData(
points = gradientState.points,
stepsX = gradientState.resolutionX,
stepsY = gradientState.resolutionY
),
size = Size(width.toFloat(), height.toFloat()),
paint = paint
)
}
}
}
private fun IntegerSize.toSize(): Size = Size(
width.coerceAtLeast(1).toFloat(),
height.coerceAtLeast(1).toFloat(),
)
private fun Canvas.drawMeshGradient(
pointData: PointData,
indicesModifier: (List<Int>) -> List<Int> = { it },
size: Size,
paint: Paint
) {
CanvasDrawScope().apply {
drawContext.canvas = this@drawMeshGradient
drawContext.size = size
with(drawContext.canvas) {
scale(
scaleX = size.width,
scaleY = size.height,
pivot = Offset.Zero
) {
drawVertices(
vertices = Vertices(
vertexMode = VertexMode.Triangles,
positions = pointData.offsets,
textureCoordinates = pointData.offsets,
colors = pointData.colors,
indices = indicesModifier(pointData.indices)
),
blendMode = BlendMode.Dst,
paint = paint,
)
}
}
}
}
}
internal class PointData(
private val points: List<List<Pair<Offset, Color>>>,
private val stepsX: Int,
private val stepsY: Int
) {
val offsets: MutableList<Offset>
val colors: MutableList<Color>
val indices: List<Int>
private val xLength: Int = (points[0].size * stepsX) - (stepsX - 1)
private val yLength: Int = (points.size * stepsY) - (stepsY - 1)
private val measure = PathMeasure()
private val indicesBlocks: List<IndicesBlock>
init {
offsets = buildList {
repeat((xLength - 0) * (yLength - 0)) {
add(Offset(0f, 0f))
}
}.toMutableList()
colors = buildList {
repeat((xLength - 0) * (yLength - 0)) {
add(Color.Transparent)
}
}.toMutableList()
indicesBlocks =
buildList {
for (y in 0..yLength - 2) {
for (x in 0..xLength - 2) {
val a = (y * xLength) + x
val b = a + 1
val c = ((y + 1) * xLength) + x
val d = c + 1
add(
IndicesBlock(
indices = buildList {
add(a)
add(c)
add(d)
add(a)
add(b)
add(d)
},
x = x, y = y
)
)
}
}
}
indices = indicesBlocks.flatMap { it.indices }
generateInterpolatedOffsets()
}
private fun generateInterpolatedOffsets() {
for (y in 0..points.lastIndex) {
for (x in 0..points[y].lastIndex) {
this[x * stepsX, y * stepsY] = points[y][x].first
this[x * stepsX, y * stepsY] = points[y][x].second
if (x != points[y].lastIndex) {
val path = cubicPathX(
point1 = points[y][x].first,
point2 = points[y][x + 1].first,
when (x) {
0 -> 0
points[y].lastIndex - 1 -> 2
else -> 1
}
)
measure.setPath(path, false)
for (i in 1..<stepsX) {
measure.getPosition(i / stepsX.toFloat() * measure.length).let {
this[(x * stepsX) + i, (y * stepsY)] = Offset(it.x, it.y)
this[(x * stepsX) + i, (y * stepsY)] =
lerp(
points[y][x].second,
points[y][x + 1].second,
i / stepsX.toFloat(),
)
}
}
}
}
}
for (y in 0..<points.lastIndex) {
for (x in 0..<this.xLength) {
val path = cubicPathY(
point1 = this[x, y * stepsY].let { Offset(it.x, it.y) },
point2 = this[x, (y + 1) * stepsY].let { Offset(it.x, it.y) },
when (y) {
0 -> 0
points[y].lastIndex - 1 -> 2
else -> 1
}
)
measure.setPath(path, false)
for (i in (1..<stepsY)) {
val point3 = measure.getPosition(i / stepsY.toFloat() * measure.length).let {
Offset(it.x, it.y)
}
this[x, ((y * stepsY) + i)] = point3
this[x, ((y * stepsY) + i)] = lerp(
this.getColor(x, y * stepsY),
this.getColor(x, (y + 1) * stepsY),
i / stepsY.toFloat(),
)
}
}
}
}
data class IndicesBlock(
val indices: List<Int>,
val x: Int,
val y: Int
)
operator fun get(
x: Int,
y: Int
): Offset {
val index = (y * xLength) + x
return offsets[index]
}
private fun getColor(
x: Int,
y: Int
): Color {
val index = (y * xLength) + x
return colors[index]
}
private operator fun set(
x: Int,
y: Int,
offset: Offset
) {
val index = (y * xLength) + x
offsets[index] = Offset(offset.x, offset.y)
}
private operator fun set(
x: Int,
y: Int,
color: Color
) {
val index = (y * xLength) + x
colors[index] = color
}
}
private fun cubicPathX(
point1: Offset,
point2: Offset,
position: Int
): Path {
val path = Path().apply {
moveTo(point1.x, point1.y)
val delta = (point2.x - point1.x) * .5f
when (position) {
0 -> cubicTo(
point1.x, point1.y,
point2.x - delta, point2.y,
point2.x, point2.y
)
2 -> cubicTo(
point1.x + delta, point1.y,
point2.x, point2.y,
point2.x, point2.y
)
else -> cubicTo(
point1.x + delta, point1.y,
point2.x - delta, point2.y,
point2.x, point2.y
)
}
lineTo(point2.x, point2.y)
}
return path
}
private fun cubicPathY(
point1: Offset,
point2: Offset,
position: Int
): Path {
val path = Path().apply {
moveTo(point1.x, point1.y)
val delta = (point2.y - point1.y) * .5f
when (position) {
0 -> cubicTo(
point1.x, point1.y,
point2.x, point2.y - delta,
point2.x, point2.y
)
2 -> cubicTo(
point1.x, point1.y + delta,
point2.x, point2.y,
point2.x, point2.y
)
else -> cubicTo(
point1.x, point1.y + delta,
point2.x, point2.y - delta,
point2.x, point2.y
)
}
lineTo(point2.x, point2.y)
}
return path
}
@@ -0,0 +1,44 @@
/*
* 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 com.t8rin.imagetoolbox.feature.gradient_maker.di
import android.graphics.Bitmap
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.geometry.Size
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.ShaderBrush
import androidx.compose.ui.graphics.TileMode
import com.t8rin.imagetoolbox.feature.gradient_maker.data.AndroidGradientMaker
import com.t8rin.imagetoolbox.feature.gradient_maker.domain.GradientMaker
import dagger.Binds
import dagger.Module
import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent
import javax.inject.Singleton
@Module
@InstallIn(SingletonComponent::class)
internal interface GradientMakerModule {
@Singleton
@Binds
fun provideGradientMaker(
maker: AndroidGradientMaker
): GradientMaker<Bitmap, ShaderBrush, Size, Color, TileMode, Offset>
}
@@ -0,0 +1,46 @@
/*
* 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 com.t8rin.imagetoolbox.feature.gradient_maker.domain
import com.t8rin.imagetoolbox.core.domain.model.IntegerSize
interface GradientMaker<Image, Brush, Size, Color, TileMode, Offset> {
suspend fun createGradient(
integerSize: IntegerSize,
gradientState: GradientState<Brush, Size, Color, TileMode, Offset>
): Image?
suspend fun createGradient(
src: Image,
gradientState: GradientState<Brush, Size, Color, TileMode, Offset>,
gradientAlpha: Float
): Image?
suspend fun createMeshGradient(
integerSize: IntegerSize,
gradientState: MeshGradientState<Color, Offset>,
): Image?
suspend fun createMeshGradient(
src: Image,
gradientState: MeshGradientState<Color, Offset>,
gradientAlpha: Float
): Image?
}
@@ -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 com.t8rin.imagetoolbox.feature.gradient_maker.domain
interface GradientState<Brush, Size, Color, TileMode, Offset> {
var size: Size
val brush: Brush?
get() = getBrush(size)
fun getBrush(size: Size): Brush?
var gradientType: GradientType
val colorStops: List<Pair<Float, Color>>
var tileMode: TileMode
var linearGradientAngle: Float
var centerFriction: Offset
var radiusFriction: Float
}
@@ -0,0 +1,22 @@
/*
* 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 com.t8rin.imagetoolbox.feature.gradient_maker.domain
enum class GradientType {
Linear, Radial, Sweep
}
@@ -0,0 +1,24 @@
/*
* ImageToolbox is an image editor for android
* Copyright (c) 2025 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 com.t8rin.imagetoolbox.feature.gradient_maker.domain
interface MeshGradientState<Color, Offset> {
val points: List<List<Pair<Offset, Color>>>
var resolutionX: Int
var resolutionY: Int
}
@@ -0,0 +1,174 @@
/*
* ImageToolbox is an image editor for android
* Copyright (c) 2026 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 com.t8rin.imagetoolbox.feature.gradient_maker.presentation
import android.net.Uri
import androidx.compose.animation.core.animateDpAsState
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import com.t8rin.imagetoolbox.core.resources.R
import com.t8rin.imagetoolbox.core.ui.utils.content_pickers.rememberImagePicker
import com.t8rin.imagetoolbox.core.ui.utils.helper.Clipboard
import com.t8rin.imagetoolbox.core.ui.widget.AdaptiveLayoutScreen
import com.t8rin.imagetoolbox.core.ui.widget.buttons.ShareButton
import com.t8rin.imagetoolbox.core.ui.widget.buttons.ShowOriginalButton
import com.t8rin.imagetoolbox.core.ui.widget.dialogs.ExitWithoutSavingDialog
import com.t8rin.imagetoolbox.core.ui.widget.dialogs.LoadingDialog
import com.t8rin.imagetoolbox.core.ui.widget.other.TopAppBarEmoji
import com.t8rin.imagetoolbox.core.ui.widget.sheets.ProcessImagesPreferenceSheet
import com.t8rin.imagetoolbox.core.ui.widget.text.TopAppBarTitle
import com.t8rin.imagetoolbox.feature.gradient_maker.presentation.components.GradientMakerAppColorSchemeHandler
import com.t8rin.imagetoolbox.feature.gradient_maker.presentation.components.GradientMakerBottomButtons
import com.t8rin.imagetoolbox.feature.gradient_maker.presentation.components.GradientMakerCompareButton
import com.t8rin.imagetoolbox.feature.gradient_maker.presentation.components.GradientMakerControls
import com.t8rin.imagetoolbox.feature.gradient_maker.presentation.components.GradientMakerImagePreview
import com.t8rin.imagetoolbox.feature.gradient_maker.presentation.components.GradientMakerNoDataControls
import com.t8rin.imagetoolbox.feature.gradient_maker.presentation.components.model.GradientMakerType
import com.t8rin.imagetoolbox.feature.gradient_maker.presentation.screenLogic.GradientMakerComponent
@Composable
fun GradientMakerContent(
component: GradientMakerComponent
) {
val screenType = component.screenType
GradientMakerAppColorSchemeHandler(component)
LaunchedEffect(screenType) {
if (screenType == null) {
component.resetState()
}
}
val goBack = {
if (screenType != null) {
component.resetState()
} else {
component.onGoBack()
}
}
var showExitDialog by rememberSaveable { mutableStateOf(false) }
val imagePicker = rememberImagePicker { uris: List<Uri> ->
component.setUris(uris)
component.updateGradientAlpha(0.5f)
}
AdaptiveLayoutScreen(
shouldDisableBackHandler = screenType == null,
canShowScreenData = screenType != null,
title = {
TopAppBarTitle(
title = when (screenType) {
null, GradientMakerType.Default -> stringResource(R.string.gradient_maker)
GradientMakerType.Overlay -> stringResource(R.string.gradient_maker_type_image)
GradientMakerType.Mesh -> stringResource(R.string.mesh_gradients)
GradientMakerType.MeshOverlay -> stringResource(R.string.gradient_maker_type_image_mesh)
},
input = Unit,
isLoading = false,
size = null
)
},
onGoBack = {
if (component.haveChanges) showExitDialog = true
else goBack()
},
actions = {
if (component.uris.isNotEmpty()) {
ShowOriginalButton(
onStateChange = component::setShowOriginal
)
}
var editSheetData by remember {
mutableStateOf(listOf<Uri>())
}
ShareButton(
enabled = component.brush != null,
onShare = component::shareBitmaps,
onCopy = {
component.cacheCurrentImage(Clipboard::copy)
},
onEdit = {
component.cacheImages {
editSheetData = it
}
}
)
ProcessImagesPreferenceSheet(
uris = editSheetData,
visible = editSheetData.isNotEmpty(),
onDismiss = {
editSheetData = emptyList()
},
onNavigate = component.onNavigate
)
},
topAppBarPersistentActions = {
if (screenType == null) {
TopAppBarEmoji()
}
GradientMakerCompareButton(component)
},
imagePreview = {
GradientMakerImagePreview(component)
},
controls = {
GradientMakerControls(component)
},
insetsForNoData = WindowInsets(0),
noDataControls = {
GradientMakerNoDataControls(component)
},
buttons = { actions ->
GradientMakerBottomButtons(
component = component,
actions = actions,
imagePicker = imagePicker
)
},
forceImagePreviewToMax = component.showOriginal,
contentPadding = animateDpAsState(
if (screenType == null) 12.dp
else 20.dp
).value
)
LoadingDialog(
visible = component.isSaving || component.isImageLoading,
done = component.done,
left = component.left,
onCancelLoading = component::cancelSaving,
canCancel = component.isSaving,
)
ExitWithoutSavingDialog(
onExit = goBack,
onDismiss = { showExitDialog = false },
visible = showExitDialog
)
}
@@ -0,0 +1,289 @@
/*
* ImageToolbox is an image editor for android
* Copyright (c) 2026 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 com.t8rin.imagetoolbox.feature.gradient_maker.presentation.components
import android.graphics.Bitmap
import androidx.compose.foundation.gestures.detectTapGestures
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
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.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
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.input.pointer.pointerInput
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import com.t8rin.imagetoolbox.core.resources.Icons
import com.t8rin.imagetoolbox.core.resources.R
import com.t8rin.imagetoolbox.core.resources.icons.Delete
import com.t8rin.imagetoolbox.core.resources.icons.MiniEdit
import com.t8rin.imagetoolbox.core.resources.icons.Palette
import com.t8rin.imagetoolbox.core.ui.theme.mixedContainer
import com.t8rin.imagetoolbox.core.ui.widget.color_picker.ColorInfo
import com.t8rin.imagetoolbox.core.ui.widget.color_picker.ColorPickerSheet
import com.t8rin.imagetoolbox.core.ui.widget.enhanced.EnhancedButton
import com.t8rin.imagetoolbox.core.ui.widget.enhanced.EnhancedSlider
import com.t8rin.imagetoolbox.core.ui.widget.enhanced.hapticsClickable
import com.t8rin.imagetoolbox.core.ui.widget.modifier.ShapeDefaults
import com.t8rin.imagetoolbox.core.ui.widget.modifier.container
import com.t8rin.imagetoolbox.core.ui.widget.other.ExpandableItem
import com.t8rin.imagetoolbox.core.ui.widget.other.RevealDirection
import com.t8rin.imagetoolbox.core.ui.widget.other.RevealValue
import com.t8rin.imagetoolbox.core.ui.widget.other.SwipeToReveal
import com.t8rin.imagetoolbox.core.ui.widget.other.rememberRevealState
import com.t8rin.imagetoolbox.core.ui.widget.saver.ColorSaver
import com.t8rin.imagetoolbox.core.ui.widget.text.TitleItem
import com.t8rin.imagetoolbox.core.ui.widget.value.ValueDialog
import com.t8rin.imagetoolbox.core.ui.widget.value.ValueText
import kotlinx.coroutines.launch
import kotlin.math.roundToInt
@Composable
fun ColorStopSelection(
colorStops: List<Pair<Float, Color>>,
onRemoveClick: (Int) -> Unit,
onValueChange: (Int, Pair<Float, Color>) -> Unit,
onAddColorStop: (Pair<Float, Color>) -> Unit,
colorPickerBitmap: Bitmap?
) {
var showColorPicker by rememberSaveable { mutableStateOf(false) }
ExpandableItem(
initialState = true,
modifier = Modifier.padding(1.dp),
shape = ShapeDefaults.extraLarge,
color = MaterialTheme.colorScheme.surfaceContainer,
visibleContent = {
TitleItem(text = stringResource(R.string.color_stops))
},
expandableContent = {
Column(
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(8.dp),
modifier = Modifier.padding(8.dp)
) {
colorStops.forEachIndexed { index, (value, color) ->
ColorStopSelectionItem(
value = value,
color = color,
onRemoveClick = {
onRemoveClick(index)
},
onValueChange = {
onValueChange(index, it)
},
canDelete = colorStops.size > 2,
colorPickerBitmap = colorPickerBitmap
)
}
EnhancedButton(
containerColor = MaterialTheme.colorScheme.mixedContainer,
onClick = {
showColorPicker = true
},
modifier = Modifier.padding(
horizontal = 16.dp
)
) {
Icon(
imageVector = Icons.Rounded.Palette,
contentDescription = null
)
Spacer(Modifier.width(8.dp))
Text(stringResource(id = R.string.add_color))
}
}
}
)
var color by rememberSaveable(stateSaver = ColorSaver) {
mutableStateOf(Color.Red)
}
ColorPickerSheet(
visible = showColorPicker,
onDismiss = { showColorPicker = false },
color = color,
onColorSelected = {
color = it
onAddColorStop(1f to it)
},
allowAlpha = true,
additionalContent = { onColorChange ->
GradientMakerColorPickerAdditionalContent(
bitmap = colorPickerBitmap,
onColorChange = onColorChange
)
}
)
}
@Composable
private fun ColorStopSelectionItem(
value: Float,
color: Color,
onRemoveClick: () -> Unit,
onValueChange: (Pair<Float, Color>) -> Unit,
canDelete: Boolean,
colorPickerBitmap: Bitmap?
) {
var showColorPicker by rememberSaveable { mutableStateOf(false) }
val scope = rememberCoroutineScope()
val state = rememberRevealState()
SwipeToReveal(
state = state,
enableSwipe = canDelete,
revealedContentEnd = {
Box(
Modifier
.fillMaxSize()
.container(
color = MaterialTheme.colorScheme.errorContainer,
shape = MaterialTheme.shapes.large,
autoShadowElevation = 0.dp,
resultPadding = 0.dp
)
.hapticsClickable {
scope.launch {
state.animateTo(RevealValue.Default)
}
onRemoveClick()
}
) {
Icon(
imageVector = Icons.Outlined.Delete,
contentDescription = stringResource(R.string.delete),
modifier = Modifier
.padding(16.dp)
.padding(end = 8.dp)
.align(Alignment.CenterEnd),
tint = MaterialTheme.colorScheme.onErrorContainer
)
}
},
directions = setOf(RevealDirection.EndToStart),
swipeableContent = {
Column(
modifier = Modifier
.fillMaxWidth()
.container(
shape = MaterialTheme.shapes.large,
resultPadding = 8.dp
)
.then(
if (canDelete) {
Modifier.pointerInput(Unit) {
detectTapGestures(
onPress = {
val time = System.currentTimeMillis()
awaitRelease()
if (System.currentTimeMillis() - time >= 200) {
scope.launch {
state.animateTo(RevealValue.FullyRevealedStart)
}
}
}
)
}
} else Modifier
)
) {
ColorInfo(
color = color,
onColorChange = {
onValueChange(value to it)
},
supportButtonIcon = Icons.Rounded.MiniEdit,
onSupportButtonClick = {
showColorPicker = true
}
)
Spacer(modifier = Modifier.height(8.dp))
Row(
verticalAlignment = Alignment.CenterVertically
) {
EnhancedSlider(
value = value,
onValueChange = { onValueChange(it to color) },
valueRange = 0f..1f,
modifier = Modifier.weight(1f)
)
var showValueDialog by rememberSaveable {
mutableStateOf(false)
}
val initialValue = rememberSaveable { value }
ValueText(
modifier = Modifier,
value = (value * 100).toInt(),
onClick = {
showValueDialog = true
},
onLongClick = {
onValueChange(initialValue to color)
}
)
ValueDialog(
roundTo = 0,
valueRange = 0f..100f,
valueState = (value * 100).toInt().toString(),
expanded = showValueDialog,
onDismiss = {
showValueDialog = false
},
onValueUpdate = {
onValueChange(it.roundToInt() / 100f to color)
showValueDialog = false
}
)
}
}
}
)
ColorPickerSheet(
visible = showColorPicker,
onDismiss = { showColorPicker = false },
color = color,
onColorSelected = {
onValueChange(value to it)
},
allowAlpha = true,
additionalContent = { onColorChange ->
GradientMakerColorPickerAdditionalContent(
bitmap = colorPickerBitmap,
onColorChange = onColorChange
)
}
)
}
@@ -0,0 +1,60 @@
/*
* ImageToolbox is an image editor for android
* Copyright (c) 2025 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 com.t8rin.imagetoolbox.feature.gradient_maker.presentation.components
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import com.t8rin.imagetoolbox.core.domain.model.IntegerSize
import com.t8rin.imagetoolbox.core.ui.theme.blend
import com.t8rin.imagetoolbox.core.ui.widget.utils.AutoContentBasedColors
import com.t8rin.imagetoolbox.feature.gradient_maker.presentation.screenLogic.GradientMakerComponent
@Composable
internal fun GradientMakerAppColorSchemeHandler(component: GradientMakerComponent) {
AutoContentBasedColors(
model = Triple(component.brush, component.meshPoints, component.selectedUri),
selector = { (_, uri) ->
component.createGradientBitmap(
data = uri,
integerSize = IntegerSize(1000, 1000)
)
},
allowChangeColor = component.screenType != null
)
val colorScheme = MaterialTheme.colorScheme
LaunchedEffect(component.colorStops) {
if (component.colorStops.isEmpty()) {
colorScheme.apply {
component.addColorStop(
pair = 0f to primary.blend(primaryContainer, 0.5f),
isInitial = true
)
component.addColorStop(
pair = 0.5f to secondary.blend(secondaryContainer, 0.5f),
isInitial = true
)
component.addColorStop(
pair = 1f to tertiary.blend(tertiaryContainer, 0.5f),
isInitial = true
)
}
}
}
}
@@ -0,0 +1,87 @@
/*
* ImageToolbox is an image editor for android
* Copyright (c) 2026 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 com.t8rin.imagetoolbox.feature.gradient_maker.presentation.components
import androidx.compose.foundation.layout.RowScope
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import com.t8rin.imagetoolbox.core.ui.utils.content_pickers.ImagePicker
import com.t8rin.imagetoolbox.core.ui.utils.content_pickers.Picker
import com.t8rin.imagetoolbox.core.ui.utils.helper.isPortraitOrientationAsState
import com.t8rin.imagetoolbox.core.ui.widget.buttons.BottomButtonsBlock
import com.t8rin.imagetoolbox.core.ui.widget.dialogs.OneTimeImagePickingDialog
import com.t8rin.imagetoolbox.core.ui.widget.dialogs.OneTimeSaveLocationSelectionDialog
import com.t8rin.imagetoolbox.feature.gradient_maker.presentation.components.model.canPickImage
import com.t8rin.imagetoolbox.feature.gradient_maker.presentation.screenLogic.GradientMakerComponent
@Composable
internal fun GradientMakerBottomButtons(
component: GradientMakerComponent,
actions: @Composable RowScope.() -> Unit,
imagePicker: ImagePicker
) {
val isPortrait by isPortraitOrientationAsState()
val saveBitmap: (oneTimeSaveLocationUri: String?) -> Unit = {
if (component.brush != null) {
component.saveBitmaps(
oneTimeSaveLocationUri = it
)
}
}
var showFolderSelectionDialog by rememberSaveable {
mutableStateOf(false)
}
var showOneTimeImagePickingDialog by rememberSaveable {
mutableStateOf(false)
}
BottomButtonsBlock(
isNoData = component.screenType == null,
onSecondaryButtonClick = imagePicker::pickImage,
isSecondaryButtonVisible = component.screenType.canPickImage(),
isPrimaryButtonVisible = component.brush != null,
showNullDataButtonAsContainer = true,
onPrimaryButtonClick = {
saveBitmap(null)
},
onPrimaryButtonLongClick = {
showFolderSelectionDialog = true
},
actions = {
if (isPortrait) actions()
},
onSecondaryButtonLongClick = {
showOneTimeImagePickingDialog = true
}
)
OneTimeSaveLocationSelectionDialog(
visible = showFolderSelectionDialog,
onDismiss = { showFolderSelectionDialog = false },
onSaveRequest = saveBitmap,
formatForFilenameSelection = component.getFormatForFilenameSelection()
)
OneTimeImagePickingDialog(
onDismiss = { showOneTimeImagePickingDialog = false },
picker = Picker.Multiple,
imagePicker = imagePicker,
visible = showOneTimeImagePickingDialog
)
}
@@ -0,0 +1,97 @@
/*
* ImageToolbox is an image editor for android
* Copyright (c) 2026 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 com.t8rin.imagetoolbox.feature.gradient_maker.presentation.components
import android.graphics.Bitmap
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
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.res.stringResource
import androidx.compose.ui.unit.dp
import com.t8rin.imagetoolbox.core.resources.Icons
import com.t8rin.imagetoolbox.core.resources.R
import com.t8rin.imagetoolbox.core.resources.icons.Eyedropper
import com.t8rin.imagetoolbox.core.ui.theme.mixedContainer
import com.t8rin.imagetoolbox.core.ui.theme.onMixedContainer
import com.t8rin.imagetoolbox.core.ui.widget.enhanced.hapticsClickable
import com.t8rin.imagetoolbox.core.ui.widget.modifier.ShapeDefaults
import com.t8rin.imagetoolbox.core.ui.widget.modifier.container
import com.t8rin.imagetoolbox.core.ui.widget.saver.ColorSaver
import com.t8rin.imagetoolbox.feature.pick_color.presentation.components.PickColorFromImageSheet
@Composable
internal fun GradientMakerColorPickerAdditionalContent(
bitmap: Bitmap?,
onColorChange: (Color) -> Unit
) {
bitmap ?: return
var showPickColorSheet by rememberSaveable { mutableStateOf(false) }
var pipetteColor by rememberSaveable(stateSaver = ColorSaver) {
mutableStateOf(Color.Black)
}
Row(
modifier = Modifier
.padding(bottom = 16.dp)
.container(
resultPadding = 0.dp,
color = MaterialTheme.colorScheme.mixedContainer.copy(0.7f),
shape = ShapeDefaults.extraLarge
)
.hapticsClickable {
showPickColorSheet = true
}
.padding(16.dp),
verticalAlignment = Alignment.CenterVertically
) {
Text(
text = stringResource(id = R.string.pipette),
modifier = Modifier.weight(1f),
color = MaterialTheme.colorScheme.onMixedContainer
)
Icon(
imageVector = Icons.Outlined.Eyedropper,
contentDescription = stringResource(R.string.pipette),
tint = MaterialTheme.colorScheme.onMixedContainer
)
}
PickColorFromImageSheet(
visible = showPickColorSheet,
onDismiss = {
showPickColorSheet = false
},
bitmap = bitmap,
onColorChange = {
pipetteColor = it
onColorChange(it)
},
color = pipetteColor
)
}
@@ -0,0 +1,100 @@
/*
* ImageToolbox is an image editor for android
* Copyright (c) 2026 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 com.t8rin.imagetoolbox.feature.gradient_maker.presentation.components
import android.net.Uri
import androidx.compose.foundation.layout.aspectRatio
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.RectangleShape
import com.t8rin.imagetoolbox.core.ui.widget.buttons.CompareButton
import com.t8rin.imagetoolbox.core.ui.widget.image.Picture
import com.t8rin.imagetoolbox.feature.compare.presentation.components.CompareSheet
import com.t8rin.imagetoolbox.feature.gradient_maker.presentation.components.model.canPickImage
import com.t8rin.imagetoolbox.feature.gradient_maker.presentation.components.model.isMesh
import com.t8rin.imagetoolbox.feature.gradient_maker.presentation.screenLogic.GradientMakerComponent
@Composable
internal fun GradientMakerCompareButton(component: GradientMakerComponent) {
var showCompareSheet by rememberSaveable { mutableStateOf(false) }
val screenType = component.screenType
CompareButton(
onClick = { showCompareSheet = true },
visible = component.brush != null && screenType.canPickImage() && component.selectedUri != Uri.EMPTY
)
CompareSheet(
beforeContent = {
Picture(
model = component.selectedUri,
modifier = Modifier.aspectRatio(
component.imageAspectRatio
)
)
},
afterContent = {
if (screenType.isMesh()) {
MeshGradientPreview(
meshGradientState = component.meshGradientState,
gradientAlpha = component.gradientAlpha,
allowPickingImage = screenType.canPickImage(),
gradientSize = component.gradientSize,
selectedUri = component.selectedUri,
imageAspectRatio = component.imageAspectRatio,
shape = RectangleShape
)
} else {
val gradientState = rememberGradientState()
LaunchedEffect(component.brush) {
gradientState.gradientType = component.gradientType
gradientState.linearGradientAngle = component.angle
gradientState.centerFriction = component.centerFriction
gradientState.radiusFriction = component.radiusFriction
gradientState.colorStops.apply {
clear()
addAll(component.colorStops)
}
gradientState.tileMode = component.tileMode
}
GradientPreview(
brush = gradientState.brush,
gradientAlpha = component.gradientAlpha,
allowPickingImage = screenType.canPickImage(),
gradientSize = component.gradientSize,
onSizeChanged = {
gradientState.size = it
},
selectedUri = component.selectedUri,
imageAspectRatio = component.imageAspectRatio,
shape = RectangleShape
)
}
},
visible = showCompareSheet,
onDismiss = {
showCompareSheet = false
}
)
}
@@ -0,0 +1,216 @@
/*
* ImageToolbox is an image editor for android
* Copyright (c) 2026 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 com.t8rin.imagetoolbox.feature.gradient_maker.presentation.components
import androidx.compose.animation.AnimatedContent
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.aspectRatio
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.runtime.Composable
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
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.res.stringResource
import androidx.compose.ui.unit.dp
import androidx.compose.ui.util.lerp
import com.t8rin.imagetoolbox.core.resources.Icons
import com.t8rin.imagetoolbox.core.resources.R
import com.t8rin.imagetoolbox.core.resources.icons.Build
import com.t8rin.imagetoolbox.core.resources.icons.DensitySmall
import com.t8rin.imagetoolbox.core.resources.icons.GridOn
import com.t8rin.imagetoolbox.core.ui.utils.helper.isPortraitOrientationAsState
import com.t8rin.imagetoolbox.core.ui.widget.controls.SaveExifWidget
import com.t8rin.imagetoolbox.core.ui.widget.controls.selection.AlphaSelector
import com.t8rin.imagetoolbox.core.ui.widget.controls.selection.ImageFormatSelector
import com.t8rin.imagetoolbox.core.ui.widget.enhanced.EnhancedSliderItem
import com.t8rin.imagetoolbox.core.ui.widget.image.ImageCounter
import com.t8rin.imagetoolbox.core.ui.widget.modifier.container
import com.t8rin.imagetoolbox.core.ui.widget.sheets.PickImageFromUrisSheet
import com.t8rin.imagetoolbox.core.ui.widget.text.TitleItem
import com.t8rin.imagetoolbox.feature.gradient_maker.presentation.components.model.canPickImage
import com.t8rin.imagetoolbox.feature.gradient_maker.presentation.components.model.isMesh
import com.t8rin.imagetoolbox.feature.gradient_maker.presentation.screenLogic.GradientMakerComponent
import kotlin.math.roundToInt
@Composable
internal fun GradientMakerControls(component: GradientMakerComponent) {
var showPickImageFromUrisSheet by rememberSaveable { mutableStateOf(false) }
val screenType = component.screenType
Column(
horizontalAlignment = Alignment.CenterHorizontally
) {
ImageCounter(
imageCount = component.uris.size.takeIf { it > 1 },
onRepick = {
showPickImageFromUrisSheet = true
}
)
AnimatedContent(
screenType != null && !screenType.canPickImage()
) { canChangeSize ->
if (canChangeSize) {
GradientSizeSelector(
value = component.gradientSize,
onWidthChange = component::updateWidth,
onHeightChange = component::updateHeight
)
} else {
AlphaSelector(
value = component.gradientAlpha,
onValueChange = component::updateGradientAlpha,
modifier = Modifier
)
}
}
Spacer(Modifier.height(8.dp))
if (screenType.isMesh()) {
Column(
modifier = Modifier.container(
resultPadding = 0.dp
)
) {
Spacer(Modifier.height(16.dp))
TitleItem(
text = stringResource(R.string.points_customization),
icon = Icons.Rounded.Build,
modifier = Modifier.padding(
horizontal = 16.dp
)
)
MeshGradientEditor(
state = component.meshGradientState,
modifier = Modifier
.fillMaxWidth()
.aspectRatio(1f)
.padding(16.dp),
colorPickerBitmap = component.colorPickerBitmap
)
}
Spacer(Modifier.height(8.dp))
EnhancedSliderItem(
value = component.meshGradientState.gridSize,
title = stringResource(R.string.grid_size),
icon = Icons.Outlined.GridOn,
valueRange = 2f..6f,
internalStateTransformation = { it.roundToInt() },
onValueChange = { value ->
if (value.roundToInt() != component.meshGradientState.gridSize) {
val size = value.roundToInt()
component.setResolution(lerp(1f, 16f, 2f / size))
component.meshGradientState.points.apply {
clear()
addAll(generateMesh(size))
}
}
}
)
Spacer(Modifier.height(8.dp))
EnhancedSliderItem(
value = component.meshResolutionX,
title = stringResource(R.string.resolution),
icon = Icons.Rounded.DensitySmall,
valueRange = 1f..64f,
internalStateTransformation = { it.roundToInt() },
onValueChange = component::setResolution
)
} else {
GradientTypeSelector(
value = component.gradientType,
onValueChange = component::setGradientType
) {
GradientPropertiesSelector(
gradientType = component.gradientType,
linearAngle = component.angle,
onLinearAngleChange = component::updateLinearAngle,
centerFriction = component.centerFriction,
radiusFriction = component.radiusFriction,
onRadialDimensionsChange = component::setRadialProperties
)
}
Spacer(Modifier.height(8.dp))
ColorStopSelection(
colorStops = component.colorStops,
onRemoveClick = component::removeColorStop,
onValueChange = component::updateColorStop,
onAddColorStop = component::addColorStop,
colorPickerBitmap = component.colorPickerBitmap
)
Spacer(Modifier.height(8.dp))
TileModeSelector(
value = component.tileMode,
onValueChange = component::setTileMode
)
}
if (screenType.canPickImage()) {
Spacer(Modifier.height(8.dp))
SaveExifWidget(
checked = component.keepExif,
imageFormat = component.imageFormat,
onCheckedChange = component::toggleKeepExif
)
}
Spacer(Modifier.height(8.dp))
ImageFormatSelector(
value = component.imageFormat,
forceEnabled = screenType != null && !screenType.canPickImage(),
onValueChange = component::setImageFormat
)
}
val transformations by remember(
component.brush,
screenType.isMesh(),
component.meshPoints,
component.meshResolutionX,
component.meshResolutionY,
component.gradientAlpha
) {
derivedStateOf {
listOf(
component.getGradientTransformation()
)
}
}
val isPortrait by isPortraitOrientationAsState()
PickImageFromUrisSheet(
transformations = transformations,
visible = showPickImageFromUrisSheet,
onDismiss = {
showPickImageFromUrisSheet = false
},
uris = component.uris,
selectedUri = component.selectedUri,
onUriPicked = component::updateSelectedUri,
onUriRemoved = component::updateUrisSilently,
columns = if (isPortrait) 2 else 4,
)
}
@@ -0,0 +1,67 @@
/*
* ImageToolbox is an image editor for android
* Copyright (c) 2025 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 com.t8rin.imagetoolbox.feature.gradient_maker.presentation.components
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.padding
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import com.t8rin.imagetoolbox.core.ui.widget.modifier.container
import com.t8rin.imagetoolbox.core.ui.widget.modifier.detectSwipes
import com.t8rin.imagetoolbox.feature.gradient_maker.presentation.components.model.canPickImage
import com.t8rin.imagetoolbox.feature.gradient_maker.presentation.components.model.isMesh
import com.t8rin.imagetoolbox.feature.gradient_maker.presentation.screenLogic.GradientMakerComponent
@Composable
internal fun GradientMakerImagePreview(component: GradientMakerComponent) {
val screenType = component.screenType
Box(
modifier = Modifier
.detectSwipes(
onSwipeRight = component::selectLeftUri,
onSwipeLeft = component::selectRightUri
)
.container()
.padding(4.dp),
contentAlignment = Alignment.Center
) {
if (screenType.isMesh()) {
MeshGradientPreview(
meshGradientState = component.meshGradientState,
gradientAlpha = if (component.showOriginal) 0f else component.gradientAlpha,
allowPickingImage = screenType.canPickImage(),
gradientSize = component.gradientSize,
selectedUri = component.selectedUri,
imageAspectRatio = component.imageAspectRatio
)
} else {
GradientPreview(
brush = component.brush,
gradientAlpha = if (component.showOriginal) 0f else component.gradientAlpha,
allowPickingImage = screenType.canPickImage(),
gradientSize = component.gradientSize,
onSizeChanged = component::setPreviewSize,
selectedUri = component.selectedUri,
imageAspectRatio = component.imageAspectRatio
)
}
}
}
@@ -0,0 +1,170 @@
/*
* ImageToolbox is an image editor for android
* Copyright (c) 2026 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 com.t8rin.imagetoolbox.feature.gradient_maker.presentation.components
import android.net.Uri
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.WindowInsetsSides
import androidx.compose.foundation.layout.displayCutout
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.only
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.layout.windowInsetsPadding
import com.t8rin.imagetoolbox.core.resources.Icons
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
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.res.stringResource
import androidx.compose.ui.unit.dp
import com.t8rin.imagetoolbox.core.resources.R
import com.t8rin.imagetoolbox.core.resources.icons.ImageOverlay
import com.t8rin.imagetoolbox.core.resources.icons.ImagesMode
import com.t8rin.imagetoolbox.core.resources.icons.MeshDownload
import com.t8rin.imagetoolbox.core.resources.icons.MeshGradient
import com.t8rin.imagetoolbox.core.ui.utils.content_pickers.rememberImagePicker
import com.t8rin.imagetoolbox.core.ui.utils.helper.isPortraitOrientationAsState
import com.t8rin.imagetoolbox.core.ui.utils.navigation.Screen
import com.t8rin.imagetoolbox.core.ui.widget.modifier.withModifier
import com.t8rin.imagetoolbox.core.ui.widget.preferences.PreferenceItem
import com.t8rin.imagetoolbox.feature.gradient_maker.presentation.components.model.GradientMakerType
import com.t8rin.imagetoolbox.feature.gradient_maker.presentation.screenLogic.GradientMakerComponent
@Composable
internal fun GradientMakerNoDataControls(
component: GradientMakerComponent
) {
val isPortrait by isPortraitOrientationAsState()
var requestedType by rememberSaveable(component.screenType) {
mutableStateOf<GradientMakerType?>(null)
}
val imagePicker = rememberImagePicker { uris: List<Uri> ->
component.setScreenType(requestedType)
component.setUris(uris)
component.updateGradientAlpha(0.5f)
}
val preference1 = @Composable {
val screen = remember {
Screen.GradientMaker()
}
PreferenceItem(
title = stringResource(screen.title),
subtitle = stringResource(screen.subtitle),
startIcon = screen.icon,
modifier = Modifier.fillMaxWidth(),
onClick = {
component.setScreenType(GradientMakerType.Default)
}
)
}
val preference2 = @Composable {
PreferenceItem(
title = stringResource(R.string.gradient_maker_type_image),
subtitle = stringResource(R.string.gradient_maker_type_image_sub),
startIcon = Icons.Outlined.ImagesMode,
modifier = Modifier.fillMaxWidth(),
onClick = {
requestedType = GradientMakerType.Overlay
imagePicker.pickImage()
}
)
}
val preference3 = @Composable {
PreferenceItem(
title = stringResource(R.string.mesh_gradients),
subtitle = stringResource(R.string.mesh_gradients_sub),
startIcon = Icons.Outlined.MeshGradient,
modifier = Modifier.fillMaxWidth(),
onClick = {
component.setScreenType(GradientMakerType.Mesh)
}
)
}
val preference4 = @Composable {
PreferenceItem(
title = stringResource(R.string.gradient_maker_type_image_mesh),
subtitle = stringResource(R.string.gradient_maker_type_image_mesh_sub),
startIcon = Icons.Outlined.ImageOverlay,
modifier = Modifier.fillMaxWidth(),
onClick = {
requestedType = GradientMakerType.MeshOverlay
imagePicker.pickImage()
}
)
}
val preference5 = @Composable {
PreferenceItem(
title = stringResource(R.string.collection_mesh_gradients),
subtitle = stringResource(R.string.collection_mesh_gradients_sub),
startIcon = Icons.Outlined.MeshDownload,
modifier = Modifier.fillMaxWidth(),
onClick = {
component.onNavigate(Screen.MeshGradients)
}
)
}
if (isPortrait) {
Column {
preference1()
Spacer(modifier = Modifier.height(8.dp))
preference2()
Spacer(modifier = Modifier.height(8.dp))
preference3()
Spacer(modifier = Modifier.height(8.dp))
preference4()
Spacer(modifier = Modifier.height(8.dp))
preference5()
}
} else {
Column(
horizontalAlignment = Alignment.CenterHorizontally
) {
Row(
modifier = Modifier.windowInsetsPadding(
WindowInsets.displayCutout.only(WindowInsetsSides.Horizontal)
)
) {
preference1.withModifier(modifier = Modifier.weight(1f))
Spacer(modifier = Modifier.width(8.dp))
preference2.withModifier(modifier = Modifier.weight(1f))
}
Spacer(modifier = Modifier.height(8.dp))
Row(
modifier = Modifier.windowInsetsPadding(
WindowInsets.displayCutout.only(WindowInsetsSides.Horizontal)
)
) {
preference3.withModifier(modifier = Modifier.weight(1f))
Spacer(modifier = Modifier.width(8.dp))
preference4.withModifier(modifier = Modifier.weight(1f))
}
Spacer(modifier = Modifier.height(8.dp))
preference5.withModifier(modifier = Modifier.fillMaxWidth(0.5f))
}
}
}
@@ -0,0 +1,114 @@
/*
* ImageToolbox is an image editor for android
* Copyright (c) 2026 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 com.t8rin.imagetoolbox.feature.gradient_maker.presentation.components
import android.net.Uri
import androidx.compose.animation.AnimatedContent
import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.aspectRatio
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.draw.drawBehind
import androidx.compose.ui.geometry.Size
import androidx.compose.ui.graphics.ShaderBrush
import androidx.compose.ui.graphics.Shape
import androidx.compose.ui.graphics.SolidColor
import androidx.compose.ui.layout.onGloballyPositioned
import androidx.compose.ui.zIndex
import com.t8rin.colors.util.roundToTwoDigits
import com.t8rin.imagetoolbox.core.domain.model.IntegerSize
import com.t8rin.imagetoolbox.core.ui.widget.image.Picture
import com.t8rin.imagetoolbox.core.ui.widget.modifier.shimmer
import com.t8rin.imagetoolbox.core.ui.widget.modifier.transparencyChecker
@Composable
internal fun GradientPreview(
brush: ShaderBrush?,
gradientAlpha: Float,
allowPickingImage: Boolean?,
gradientSize: IntegerSize,
onSizeChanged: (Size) -> Unit,
imageAspectRatio: Float,
selectedUri: Uri,
shape: Shape = MaterialTheme.shapes.medium
) {
val alpha by animateFloatAsState(
if (brush == null) 1f
else gradientAlpha
)
val solidBrush = SolidColor(MaterialTheme.colorScheme.surfaceContainer)
AnimatedContent(
targetState = if (allowPickingImage == true) {
imageAspectRatio
} else {
gradientSize
.aspectRatio
.roundToTwoDigits()
.coerceIn(0.01f..100f)
}
) { aspectRatio ->
Box {
Box(
modifier = Modifier
.aspectRatio(aspectRatio)
.clip(shape)
.then(
if (allowPickingImage != true) {
Modifier.transparencyChecker()
} else Modifier
)
.drawBehind {
drawRect(
brush = brush ?: solidBrush,
alpha = alpha
)
}
.onGloballyPositioned {
onSizeChanged(
Size(
it.size.width.toFloat(),
it.size.height.toFloat()
)
)
}
.zIndex(2f),
contentAlignment = Alignment.Center
) {
Spacer(
modifier = Modifier
.fillMaxSize()
.shimmer(visible = brush == null)
)
}
if (allowPickingImage == true) {
Picture(
model = selectedUri,
modifier = Modifier.matchParentSize(),
shape = shape
)
}
}
}
}
@@ -0,0 +1,118 @@
/*
* ImageToolbox is an image editor for android
* Copyright (c) 2026 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 com.t8rin.imagetoolbox.feature.gradient_maker.presentation.components
import androidx.compose.animation.AnimatedContent
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.foundation.layout.Column
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableFloatStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.res.stringResource
import com.t8rin.colors.util.roundToTwoDigits
import com.t8rin.imagetoolbox.core.resources.R
import com.t8rin.imagetoolbox.core.ui.widget.enhanced.EnhancedSliderItem
import com.t8rin.imagetoolbox.feature.gradient_maker.domain.GradientType
import kotlin.math.roundToInt
@Composable
fun GradientPropertiesSelector(
gradientType: GradientType,
linearAngle: Float,
centerFriction: Offset,
radiusFriction: Float,
onLinearAngleChange: (Float) -> Unit,
onRadialDimensionsChange: (Offset, Float) -> Unit,
modifier: Modifier = Modifier
) {
AnimatedContent(
targetState = gradientType,
modifier = modifier
) { type ->
when (type) {
GradientType.Linear -> {
EnhancedSliderItem(
behaveAsContainer = false,
value = linearAngle,
title = stringResource(id = R.string.angle),
valueRange = 0f..360f,
internalStateTransformation = { it.roundToInt() },
onValueChange = {
onLinearAngleChange(it.roundToInt().toFloat())
}
)
}
GradientType.Radial,
GradientType.Sweep -> {
var centerX by remember { mutableFloatStateOf(centerFriction.x) }
var centerY by remember { mutableFloatStateOf(centerFriction.y) }
var radius by remember { mutableFloatStateOf(radiusFriction) }
onRadialDimensionsChange(Offset(centerX, centerY), radius)
Column {
EnhancedSliderItem(
value = centerX,
title = stringResource(id = R.string.center_x),
internalStateTransformation = {
it.roundToTwoDigits()
},
onValueChange = {
centerX = it
},
valueRange = 0f..1f,
behaveAsContainer = false
)
EnhancedSliderItem(
value = centerY,
title = stringResource(id = R.string.center_y),
internalStateTransformation = {
it.roundToTwoDigits()
},
onValueChange = {
centerY = it
},
valueRange = 0f..1f,
behaveAsContainer = false
)
AnimatedVisibility(
visible = type != GradientType.Sweep
) {
EnhancedSliderItem(
value = radius,
title = stringResource(id = R.string.radius),
internalStateTransformation = {
it.roundToTwoDigits()
},
onValueChange = {
radius = it
},
valueRange = 0f..1f,
behaveAsContainer = false
)
}
}
}
}
}
}
@@ -0,0 +1,102 @@
/*
* 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 com.t8rin.imagetoolbox.feature.gradient_maker.presentation.components
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.unit.dp
import com.t8rin.imagetoolbox.core.domain.model.IntegerSize
import com.t8rin.imagetoolbox.core.resources.R
import com.t8rin.imagetoolbox.core.ui.utils.helper.ImageUtils.restrict
import com.t8rin.imagetoolbox.core.ui.widget.modifier.ShapeDefaults
import com.t8rin.imagetoolbox.core.ui.widget.modifier.container
import com.t8rin.imagetoolbox.core.ui.widget.text.RoundedTextField
import com.t8rin.imagetoolbox.core.ui.widget.text.RoundedTextFieldColors
@Composable
fun GradientSizeSelector(
value: IntegerSize,
onWidthChange: (Int) -> Unit,
onHeightChange: (Int) -> Unit,
modifier: Modifier = Modifier
) {
Row(
modifier = modifier.container(
shape = ShapeDefaults.extraLarge
)
) {
val (width, height) = value
RoundedTextField(
value = width.takeIf { it != 0 }?.toString() ?: "",
onValueChange = {
onWidthChange(it.restrict(8192).toIntOrNull() ?: 0)
},
shape = ShapeDefaults.smallStart,
keyboardOptions = KeyboardOptions(
keyboardType = KeyboardType.Number
),
label = {
Text(stringResource(R.string.width, " "))
},
modifier = Modifier
.weight(1f)
.padding(
start = 8.dp,
top = 8.dp,
bottom = 8.dp,
end = 2.dp
),
colors = RoundedTextFieldColors(
isError = false,
containerColor = MaterialTheme.colorScheme.surfaceContainer
)
)
RoundedTextField(
value = height.takeIf { it != 0 }?.toString() ?: "",
onValueChange = {
onHeightChange(it.restrict(8192).toIntOrNull() ?: 0)
},
keyboardOptions = KeyboardOptions(
keyboardType = KeyboardType.Number
),
shape = ShapeDefaults.smallEnd,
label = {
Text(stringResource(R.string.height, " "))
},
modifier = Modifier
.weight(1f)
.padding(
start = 2.dp,
top = 8.dp,
bottom = 8.dp,
end = 8.dp
),
colors = RoundedTextFieldColors(
isError = false,
containerColor = MaterialTheme.colorScheme.surfaceContainer
)
)
}
}
@@ -0,0 +1,92 @@
/*
* ImageToolbox is an image editor for android
* Copyright (c) 2026 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 com.t8rin.imagetoolbox.feature.gradient_maker.presentation.components
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.padding
import com.t8rin.imagetoolbox.core.resources.Icons
import androidx.compose.material3.MaterialTheme
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.t8rin.imagetoolbox.core.resources.R
import com.t8rin.imagetoolbox.core.resources.icons.Tune
import com.t8rin.imagetoolbox.core.ui.widget.enhanced.EnhancedButtonGroup
import com.t8rin.imagetoolbox.core.ui.widget.modifier.ShapeDefaults
import com.t8rin.imagetoolbox.core.ui.widget.modifier.animateContentSizeNoClip
import com.t8rin.imagetoolbox.core.ui.widget.modifier.container
import com.t8rin.imagetoolbox.core.ui.widget.other.ExpandableItem
import com.t8rin.imagetoolbox.core.ui.widget.text.TitleItem
import com.t8rin.imagetoolbox.feature.gradient_maker.domain.GradientType
@Composable
fun GradientTypeSelector(
value: GradientType,
onValueChange: (GradientType) -> Unit,
modifier: Modifier = Modifier,
propertiesContent: @Composable () -> Unit
) {
Column(
modifier = modifier
.container(
shape = ShapeDefaults.extraLarge
)
.animateContentSizeNoClip(),
verticalArrangement = Arrangement.Center,
horizontalAlignment = Alignment.CenterHorizontally
) {
EnhancedButtonGroup(
modifier = Modifier.padding(8.dp),
enabled = true,
items = GradientType.entries.map { it.translatedName },
selectedIndex = GradientType.entries.indexOf(value),
title = stringResource(id = R.string.gradient_type),
onIndexChange = {
onValueChange(GradientType.entries[it])
}
)
ExpandableItem(
visibleContent = {
TitleItem(
text = stringResource(id = R.string.properties),
icon = Icons.Rounded.Tune
)
},
expandableContent = {
Column(
modifier = Modifier.padding(horizontal = 8.dp)
) {
propertiesContent()
}
},
modifier = Modifier.padding(end = 8.dp, start = 8.dp, bottom = 8.dp),
color = MaterialTheme.colorScheme.surface
)
}
}
private val GradientType.translatedName: String
@Composable
get() = when (this) {
GradientType.Linear -> stringResource(id = R.string.gradient_type_linear)
GradientType.Radial -> stringResource(id = R.string.gradient_type_radial)
GradientType.Sweep -> stringResource(id = R.string.gradient_type_sweep)
}
@@ -0,0 +1,272 @@
/*
* ImageToolbox is an image editor for android
* Copyright (c) 2026 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 com.t8rin.imagetoolbox.feature.gradient_maker.presentation.components
import android.graphics.Bitmap
import androidx.compose.foundation.Canvas
import androidx.compose.foundation.gestures.detectDragGestures
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.runtime.Composable
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.saveable.Saver
import androidx.compose.runtime.saveable.SaverScope
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.geometry.Size
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.ColorFilter
import androidx.compose.ui.graphics.Paint
import androidx.compose.ui.graphics.asComposePaint
import androidx.compose.ui.graphics.drawscope.translate
import androidx.compose.ui.graphics.nativePaint
import androidx.compose.ui.graphics.toArgb
import androidx.compose.ui.graphics.vector.rememberVectorPainter
import androidx.compose.ui.input.pointer.pointerInput
import com.t8rin.imagetoolbox.core.resources.Icons
import com.t8rin.imagetoolbox.core.resources.icons.MiniEditLarge
import com.t8rin.imagetoolbox.core.ui.theme.inverseByLuma
import com.t8rin.imagetoolbox.core.ui.widget.color_picker.ColorPickerSheet
import com.t8rin.imagetoolbox.core.ui.widget.modifier.ShapeDefaults
import com.t8rin.imagetoolbox.core.ui.widget.modifier.meshGradient
import com.t8rin.imagetoolbox.core.ui.widget.modifier.tappable
import com.t8rin.imagetoolbox.core.ui.widget.modifier.transparencyChecker
import com.t8rin.imagetoolbox.core.ui.widget.saver.OffsetSaver
import kotlin.math.pow
import kotlin.math.sqrt
@Composable
internal fun MeshGradientEditor(
state: UiMeshGradientState,
modifier: Modifier = Modifier,
colorPickerBitmap: Bitmap? = null
) {
val selectedPoint = rememberSaveable(
stateSaver = PairOffsetColorSaver
) { mutableStateOf(null) }
val dragOffset = rememberSaveable(
stateSaver = OffsetSaver
) { mutableStateOf(null) }
val showColorPicker = rememberSaveable { mutableStateOf(false) }
val isDragging = rememberSaveable { mutableStateOf(false) }
Box(
modifier = modifier
.clip(ShapeDefaults.extraSmall)
.transparencyChecker()
.meshGradient(
points = state.points,
resolutionX = state.resolutionX,
resolutionY = state.resolutionY
)
.tappable { tapOffset ->
val tappedPoint = state.points.flatten()
.firstOrNull { (offset, _) ->
Offset(offset.x * size.width, offset.y * size.height).getDistance(
tapOffset
) < 60f
}
showColorPicker.value = tappedPoint == selectedPoint.value
selectedPoint.value = tappedPoint
if (tappedPoint == null) dragOffset.value = null
}
) {
val painter = rememberVectorPainter(Icons.Outlined.MiniEditLarge)
Canvas(
modifier = Modifier
.fillMaxSize()
.then(
if (selectedPoint.value != null) {
Modifier.pointerInput(Unit) {
detectDragGestures(
onDragStart = { startOffset ->
val tappedPoint = state.points.flatten()
.firstOrNull { (offset, _) ->
Offset(
offset.x * size.width,
offset.y * size.height
).getDistance(startOffset) < 60f
}
if (tappedPoint != null) {
selectedPoint.value = tappedPoint
dragOffset.value = tappedPoint.first
isDragging.value = true
showColorPicker.value = false
}
},
onDrag = { _, dragAmount ->
selectedPoint.value?.let { (oldOffset, color) ->
val newOffset = Offset(
((oldOffset.x * size.width + dragAmount.x) / size.width).coerceIn(
0f,
1f
),
((oldOffset.y * size.height + dragAmount.y) / size.height).coerceIn(
0f,
1f
)
)
state.updatePointPosition(oldOffset, newOffset)
selectedPoint.value = newOffset to color
}
},
onDragEnd = {
isDragging.value = false
}
)
}
} else Modifier
)
) {
state.points.flatten().forEach { (offset, color) ->
val scaledOffset = Offset(offset.x * size.width, offset.y * size.height)
val isSelected = selectedPoint.value?.first == offset
val inverseColor = color.inverseByLuma()
drawContext.canvas.apply {
drawCircle(
radius = if (isSelected) 32f else 27f,
center = scaledOffset,
paint = Paint().nativePaint.apply {
setShadowLayer(
if (isSelected) 36f else 31f,
0f,
0f,
Color.Black.copy(alpha = if (isSelected) .8f else .4f)
.toArgb()
)
}.asComposePaint()
)
}
if (isSelected) {
drawCircle(
color = inverseColor,
radius = 40f,
center = scaledOffset
)
}
drawCircle(
color = color,
radius = if (isSelected) 35f else 30f,
center = scaledOffset
)
if (isSelected) {
translate(
scaledOffset.x - 25f,
scaledOffset.y - 25f
) {
with(painter) {
draw(
size = Size(width = 50f, height = 50f),
colorFilter = ColorFilter.tint(inverseColor)
)
}
}
}
}
}
ColorPickerSheet(
visible = showColorPicker.value,
onDismiss = { showColorPicker.value = false },
color = selectedPoint.value?.second,
onColorSelected = { newColor ->
selectedPoint.value?.let { (offset) ->
state.updatePointColor(offset, newColor)
selectedPoint.value = offset to newColor
}
},
allowAlpha = true,
additionalContent = { onColorChange ->
GradientMakerColorPickerAdditionalContent(
bitmap = colorPickerBitmap,
onColorChange = onColorChange
)
}
)
}
}
private fun UiMeshGradientState.updatePointPosition(
oldOffset: Offset,
newOffset: Offset
) {
var found = false
points.replaceAll { row ->
row.map {
if (it.first == oldOffset && !found) {
found = true
newOffset to it.second
} else {
it
}
}
}
}
private fun UiMeshGradientState.updatePointColor(
offset: Offset,
newColor: Color
) {
var found = false
points.replaceAll { row ->
row.map {
if (it.first == offset && !found) {
found = true
it.first to newColor
} else {
it
}
}
}
}
private fun Offset.getDistance(other: Offset): Float {
return sqrt((x - other.x).pow(2) + (y - other.y).pow(2))
}
private val PairOffsetColorSaver: Saver<Pair<Offset, Color>?, List<Float>> =
object : Saver<Pair<Offset, Color>?, List<Float>> {
override fun restore(value: List<Float>): Pair<Offset, Color>? {
return if (value.size == 5) {
val offset = Offset(value[0], value[1])
val color = Color(value[2], value[3], value[4])
offset to color
} else {
null
}
}
override fun SaverScope.save(value: Pair<Offset, Color>?): List<Float> {
return if (value == null) emptyList()
else listOf(
value.first.x, value.first.y,
value.second.red, value.second.green, value.second.blue
)
}
}
@@ -0,0 +1,88 @@
/*
* ImageToolbox is an image editor for android
* Copyright (c) 2026 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 com.t8rin.imagetoolbox.feature.gradient_maker.presentation.components
import android.net.Uri
import androidx.compose.animation.AnimatedContent
import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.aspectRatio
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Shape
import androidx.compose.ui.zIndex
import com.t8rin.colors.util.roundToTwoDigits
import com.t8rin.imagetoolbox.core.domain.model.IntegerSize
import com.t8rin.imagetoolbox.core.ui.widget.image.Picture
import com.t8rin.imagetoolbox.core.ui.widget.modifier.meshGradient
import com.t8rin.imagetoolbox.core.ui.widget.modifier.transparencyChecker
@Composable
internal fun MeshGradientPreview(
meshGradientState: UiMeshGradientState,
gradientAlpha: Float,
allowPickingImage: Boolean?,
gradientSize: IntegerSize,
imageAspectRatio: Float,
selectedUri: Uri,
shape: Shape = MaterialTheme.shapes.medium
) {
val alpha by animateFloatAsState(gradientAlpha)
AnimatedContent(
targetState = if (allowPickingImage == true) {
imageAspectRatio
} else {
gradientSize
.aspectRatio
.roundToTwoDigits()
.coerceIn(0.01f..100f)
}
) { aspectRatio ->
Box {
Spacer(
modifier = Modifier
.aspectRatio(aspectRatio)
.clip(shape)
.then(
if (allowPickingImage != true) {
Modifier.transparencyChecker()
} else Modifier
)
.meshGradient(
points = meshGradientState.points,
resolutionX = meshGradientState.resolutionX,
resolutionY = meshGradientState.resolutionY,
alpha = alpha
)
.zIndex(2f)
)
if (allowPickingImage == true) {
Picture(
model = selectedUri,
modifier = Modifier.matchParentSize(),
shape = shape,
size = 1500
)
}
}
}
}
@@ -0,0 +1,77 @@
/*
* 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 com.t8rin.imagetoolbox.feature.gradient_maker.presentation.components
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.padding
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.TileMode
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import com.t8rin.imagetoolbox.core.resources.R
import com.t8rin.imagetoolbox.core.ui.widget.enhanced.EnhancedButtonGroup
import com.t8rin.imagetoolbox.core.ui.widget.modifier.ShapeDefaults
import com.t8rin.imagetoolbox.core.ui.widget.modifier.animateContentSizeNoClip
import com.t8rin.imagetoolbox.core.ui.widget.modifier.container
@Composable
fun TileModeSelector(
value: TileMode,
onValueChange: (TileMode) -> Unit,
modifier: Modifier = Modifier
) {
val entries = remember {
listOf(
TileMode.Clamp,
TileMode.Repeated,
TileMode.Mirror,
TileMode.Decal
)
}
Box(
modifier = modifier
.container(
shape = ShapeDefaults.extraLarge
)
.animateContentSizeNoClip(),
contentAlignment = Alignment.Center
) {
EnhancedButtonGroup(
modifier = Modifier.padding(8.dp),
enabled = true,
items = entries.map { it.translatedName },
selectedIndex = entries.indexOf(value),
title = stringResource(id = R.string.tile_mode),
onIndexChange = {
onValueChange(entries[it])
}
)
}
}
private val TileMode.translatedName: String
@Composable
get() = when (this) {
TileMode.Repeated -> stringResource(id = R.string.tile_mode_repeated)
TileMode.Mirror -> stringResource(id = R.string.tile_mode_mirror)
TileMode.Decal -> stringResource(id = R.string.tile_mode_decal)
else -> stringResource(id = R.string.tile_mode_clamp)
}
@@ -0,0 +1,173 @@
/*
* 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 com.t8rin.imagetoolbox.feature.gradient_maker.presentation.components
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableFloatStateOf
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateListOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.geometry.Size
import androidx.compose.ui.geometry.center
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.ShaderBrush
import androidx.compose.ui.graphics.TileMode
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.unit.DpSize
import com.t8rin.imagetoolbox.feature.gradient_maker.domain.GradientState
import com.t8rin.imagetoolbox.feature.gradient_maker.domain.GradientType
import com.t8rin.imagetoolbox.feature.gradient_maker.domain.MeshGradientState
import kotlin.math.PI
import kotlin.math.cos
import kotlin.math.min
import kotlin.math.pow
import kotlin.math.sin
import kotlin.math.sqrt
import kotlin.random.Random
@Composable
fun rememberGradientState(
size: DpSize = DpSize.Zero
): UiGradientState {
val density = LocalDensity.current
return remember {
val sizePx = if (size == DpSize.Zero) {
Size.Zero
} else {
with(density) {
Size(
size.width.toPx(),
size.height.toPx()
)
}
}
UiGradientState(sizePx)
}
}
class UiGradientState(
size: Size = Size.Zero
) : GradientState<ShaderBrush, Size, Color, TileMode, Offset> {
override var size by mutableStateOf(size)
override val brush: ShaderBrush?
get() = getBrush(size)
override fun getBrush(size: Size): ShaderBrush? {
if (colorStops.isEmpty()) return null
val colorStops = colorStops.sortedBy { it.first }.let { pairs ->
if (gradientType != GradientType.Sweep) {
pairs.distinctBy { it.first }
} else pairs
}.let {
if (it.size == 1) {
listOf(it.first(), it.first())
} else it
}.toTypedArray()
val brush = runCatching {
when (gradientType) {
GradientType.Linear -> {
val angleRad = linearGradientAngle / 180f * PI
val x = cos(angleRad).toFloat()
val y = sin(angleRad).toFloat()
val radius = sqrt(size.width.pow(2) + size.height.pow(2)) / 2f
val offset = size.center + Offset(x * radius, y * radius)
val exactOffset = Offset(
x = min(offset.x.coerceAtLeast(0f), size.width),
y = size.height - min(offset.y.coerceAtLeast(0f), size.height)
)
Brush.linearGradient(
colorStops = colorStops,
start = Offset(size.width, size.height) - exactOffset,
end = exactOffset,
tileMode = tileMode
)
}
GradientType.Radial -> Brush.radialGradient(
colorStops = colorStops,
center = Offset(
x = size.width * centerFriction.x,
y = size.height * centerFriction.y
),
radius = ((size.width.coerceAtLeast(size.height)) / 2 * radiusFriction)
.coerceAtLeast(0.01f),
tileMode = tileMode
)
GradientType.Sweep -> Brush.sweepGradient(
colorStops = colorStops,
center = Offset(
x = size.width * centerFriction.x,
y = size.height * centerFriction.y
)
)
} as ShaderBrush
}.getOrNull()
return brush
}
override var gradientType: GradientType by mutableStateOf(GradientType.Linear)
override val colorStops = mutableStateListOf<Pair<Float, Color>>()
override var tileMode by mutableStateOf(TileMode.Clamp)
override var linearGradientAngle by mutableFloatStateOf(0f)
override var centerFriction by mutableStateOf(Offset(.5f, .5f))
override var radiusFriction by mutableFloatStateOf(.5f)
}
class UiMeshGradientState : MeshGradientState<Color, Offset> {
override val points = mutableStateListOf<List<Pair<Offset, Color>>>().apply {
addAll(generateMesh(2))
}
val gridSize: Int
get() = points.firstOrNull()?.size ?: 0
override var resolutionX: Int by mutableIntStateOf(16)
override var resolutionY: Int by mutableIntStateOf(16)
}
fun generateMesh(size: Int): List<List<Pair<Offset, Color>>> {
return List(size) { y ->
List(size) { x ->
Offset(x / (size - 1f), y / (size - 1f)) to Color.random()
}
}
}
private fun Color.Companion.random(): Color = Color(Random.nextInt()).copy(1f)
@@ -0,0 +1,31 @@
/*
* ImageToolbox is an image editor for android
* Copyright (c) 2025 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 com.t8rin.imagetoolbox.feature.gradient_maker.presentation.components.model
enum class GradientMakerType {
Default,
Overlay,
Mesh,
MeshOverlay
}
fun GradientMakerType?.isMesh() =
this == GradientMakerType.Mesh || this == GradientMakerType.MeshOverlay
fun GradientMakerType?.canPickImage() =
this == GradientMakerType.Overlay || this == GradientMakerType.MeshOverlay
@@ -0,0 +1,615 @@
/*
* ImageToolbox is an image editor for android
* Copyright (c) 2026 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 com.t8rin.imagetoolbox.feature.gradient_maker.presentation.screenLogic
import android.graphics.Bitmap
import android.net.Uri
import androidx.compose.runtime.MutableState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableFloatStateOf
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.geometry.Size
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.ShaderBrush
import androidx.compose.ui.graphics.TileMode
import androidx.core.net.toUri
import coil3.transform.Transformation
import com.arkivanov.decompose.ComponentContext
import com.t8rin.imagetoolbox.core.data.utils.toCoil
import com.t8rin.imagetoolbox.core.domain.coroutines.DispatchersHolder
import com.t8rin.imagetoolbox.core.domain.image.ImageCompressor
import com.t8rin.imagetoolbox.core.domain.image.ImageGetter
import com.t8rin.imagetoolbox.core.domain.image.ImageShareProvider
import com.t8rin.imagetoolbox.core.domain.image.model.ImageFormat
import com.t8rin.imagetoolbox.core.domain.image.model.ImageInfo
import com.t8rin.imagetoolbox.core.domain.model.IntegerSize
import com.t8rin.imagetoolbox.core.domain.saving.FileController
import com.t8rin.imagetoolbox.core.domain.saving.model.ImageSaveTarget
import com.t8rin.imagetoolbox.core.domain.saving.model.SaveResult
import com.t8rin.imagetoolbox.core.domain.saving.model.onSuccess
import com.t8rin.imagetoolbox.core.domain.saving.updateProgress
import com.t8rin.imagetoolbox.core.domain.transformation.GenericTransformation
import com.t8rin.imagetoolbox.core.domain.utils.ListUtils.leftFrom
import com.t8rin.imagetoolbox.core.domain.utils.ListUtils.rightFrom
import com.t8rin.imagetoolbox.core.domain.utils.smartJob
import com.t8rin.imagetoolbox.core.ui.utils.BaseComponent
import com.t8rin.imagetoolbox.core.ui.utils.helper.AppToastHost
import com.t8rin.imagetoolbox.core.ui.utils.helper.ImageUtils.safeAspectRatio
import com.t8rin.imagetoolbox.core.ui.utils.navigation.Screen
import com.t8rin.imagetoolbox.core.ui.utils.state.update
import com.t8rin.imagetoolbox.feature.gradient_maker.domain.GradientMaker
import com.t8rin.imagetoolbox.feature.gradient_maker.domain.GradientType
import com.t8rin.imagetoolbox.feature.gradient_maker.presentation.components.UiGradientState
import com.t8rin.imagetoolbox.feature.gradient_maker.presentation.components.UiMeshGradientState
import com.t8rin.imagetoolbox.feature.gradient_maker.presentation.components.model.GradientMakerType
import com.t8rin.imagetoolbox.feature.gradient_maker.presentation.components.model.isMesh
import dagger.assisted.Assisted
import dagger.assisted.AssistedFactory
import dagger.assisted.AssistedInject
import kotlinx.coroutines.Job
import kotlin.math.roundToInt
class GradientMakerComponent @AssistedInject internal constructor(
@Assisted componentContext: ComponentContext,
@Assisted val initialUris: List<Uri>?,
@Assisted val onGoBack: () -> Unit,
@Assisted val onNavigate: (Screen) -> Unit,
private val fileController: FileController,
private val imageCompressor: ImageCompressor<Bitmap>,
private val shareProvider: ImageShareProvider<Bitmap>,
private val imageGetter: ImageGetter<Bitmap>,
private val gradientMaker: GradientMaker<Bitmap, ShaderBrush, Size, Color, TileMode, Offset>,
dispatchersHolder: DispatchersHolder
) : BaseComponent(dispatchersHolder, componentContext) {
init {
debounce {
initialUris?.let(::setUris)
resetState()
}
}
private val _screenType: MutableState<GradientMakerType?> = mutableStateOf(null)
val screenType by _screenType
private val _showOriginal: MutableState<Boolean> = mutableStateOf(false)
val showOriginal by _showOriginal
private var _gradientState = UiGradientState()
private val gradientState: UiGradientState get() = _gradientState
private var _meshGradientState = UiMeshGradientState()
val meshGradientState: UiMeshGradientState get() = _meshGradientState
val meshResolutionX: Int get() = meshGradientState.resolutionX
val meshResolutionY: Int get() = meshGradientState.resolutionY
val meshPoints: List<List<Pair<Offset, Color>>> get() = meshGradientState.points
val brush: ShaderBrush? get() = gradientState.brush
val gradientType: GradientType get() = gradientState.gradientType
val colorStops: List<Pair<Float, Color>> get() = gradientState.colorStops
val tileMode: TileMode get() = gradientState.tileMode
val angle: Float get() = gradientState.linearGradientAngle
val centerFriction: Offset get() = gradientState.centerFriction
val radiusFriction: Float get() = gradientState.radiusFriction
private var _gradientAlpha: MutableState<Float> = mutableFloatStateOf(1f)
val gradientAlpha by _gradientAlpha
private val _keepExif = mutableStateOf(false)
val keepExif by _keepExif
private val _selectedUri = mutableStateOf(Uri.EMPTY)
val selectedUri: Uri by _selectedUri
private val _colorPickerBitmap: MutableState<Bitmap?> = mutableStateOf(null)
val colorPickerBitmap: Bitmap? by _colorPickerBitmap
private val _uris = mutableStateOf(emptyList<Uri>())
val uris by _uris
private val _imageAspectRatio: MutableState<Float> = mutableFloatStateOf(1f)
val imageAspectRatio by _imageAspectRatio
private val _isSaving: MutableState<Boolean> = mutableStateOf(false)
val isSaving: Boolean by _isSaving
private val _imageFormat = mutableStateOf(ImageFormat.Default)
val imageFormat by _imageFormat
private val _gradientSize: MutableState<IntegerSize> = mutableStateOf(IntegerSize(1000, 1000))
val gradientSize by _gradientSize
suspend fun createGradientBitmap(
data: Any,
integerSize: IntegerSize = gradientSize,
useBitmapOriginalSizeIfAvailable: Boolean = false
): Bitmap? {
return if (selectedUri == Uri.EMPTY) {
if (screenType.isMesh()) {
gradientMaker.createMeshGradient(
integerSize = integerSize,
gradientState = meshGradientState
)
} else {
gradientMaker.createGradient(
integerSize = integerSize,
gradientState = gradientState
)
}
} else {
imageGetter.getImage(
data = data,
originalSize = useBitmapOriginalSizeIfAvailable
)?.let {
if (screenType.isMesh()) {
gradientMaker.createMeshGradient(
src = it,
gradientState = meshGradientState,
gradientAlpha = gradientAlpha
)
} else {
gradientMaker.createGradient(
src = it,
gradientState = gradientState,
gradientAlpha = gradientAlpha
)
}
}
}
}
private val _done: MutableState<Int> = mutableIntStateOf(0)
val done by _done
private val _left: MutableState<Int> = mutableIntStateOf(-1)
val left by _left
private var savingJob: Job? by smartJob {
_isSaving.update { false }
}
fun saveBitmaps(
oneTimeSaveLocationUri: String?
) {
savingJob = trackProgress {
_left.value = -1
_isSaving.value = true
if (uris.isEmpty()) {
createGradientBitmap(
data = Unit,
useBitmapOriginalSizeIfAvailable = true
)?.let { localBitmap ->
val imageInfo = ImageInfo(
imageFormat = imageFormat,
width = localBitmap.width,
height = localBitmap.height
)
parseSaveResult(
fileController.save(
saveTarget = ImageSaveTarget(
imageInfo = imageInfo,
originalUri = "",
sequenceNumber = null,
data = imageCompressor.compressAndTransform(
image = localBitmap,
imageInfo = imageInfo
)
),
keepOriginalMetadata = false,
oneTimeSaveLocationUri = oneTimeSaveLocationUri
).onSuccess(::registerSave)
)
}
} else {
val results = mutableListOf<SaveResult>()
_done.value = 0
_left.value = uris.size
uris.forEach { uri ->
createGradientBitmap(
data = uri,
useBitmapOriginalSizeIfAvailable = true
)?.let { localBitmap ->
val imageInfo = ImageInfo(
imageFormat = imageFormat,
width = localBitmap.width,
height = localBitmap.height,
originalUri = uri.toString()
)
results.add(
fileController.save(
saveTarget = ImageSaveTarget(
imageInfo = imageInfo,
originalUri = uri.toString(),
sequenceNumber = _done.value + 1,
data = imageCompressor.compressAndTransform(
image = localBitmap,
imageInfo = imageInfo
)
),
keepOriginalMetadata = keepExif,
oneTimeSaveLocationUri = oneTimeSaveLocationUri
)
)
} ?: results.add(
SaveResult.Error.Exception(Throwable())
)
_done.value += 1
updateProgress(
done = done,
total = left
)
}
parseSaveResults(results.onSuccess(::registerSave))
}
_isSaving.value = false
}
}
fun shareBitmaps() {
savingJob = trackProgress {
_left.value = -1
_isSaving.value = true
if (uris.isEmpty()) {
createGradientBitmap(
data = Unit,
useBitmapOriginalSizeIfAvailable = true
)?.let {
shareProvider.shareImage(
image = it,
imageInfo = ImageInfo(
imageFormat = imageFormat,
width = it.width,
height = it.height
),
onComplete = AppToastHost::showConfetti
)
}
} else {
_done.value = 0
_left.value = uris.size
shareProvider.shareImages(
uris.map { it.toString() },
imageLoader = { uri ->
createGradientBitmap(
data = uri,
useBitmapOriginalSizeIfAvailable = true
)?.let {
it to ImageInfo(
width = it.width,
height = it.height,
imageFormat = imageFormat
)
}
},
onProgressChange = {
if (it == -1) {
AppToastHost.showConfetti()
_isSaving.value = false
_done.value = 0
} else {
_done.value = it
}
updateProgress(
done = done,
total = left
)
}
)
}
_isSaving.value = false
_left.value = -1
}
}
fun cancelSaving() {
savingJob = null
_isSaving.value = false
_left.value = -1
}
fun updateHeight(value: Int) {
_gradientSize.update {
it.copy(height = value)
}
registerChanges()
}
fun updateWidth(value: Int) {
_gradientSize.update {
it.copy(width = value)
}
registerChanges()
}
fun setGradientType(gradientType: GradientType) {
gradientState.gradientType = gradientType
registerChanges()
}
fun setPreviewSize(size: Size) {
gradientState.size = size
}
fun setImageFormat(imageFormat: ImageFormat) {
_imageFormat.update { imageFormat }
registerChanges()
}
fun updateLinearAngle(angle: Float) {
gradientState.linearGradientAngle = angle
registerChanges()
}
fun setRadialProperties(
center: Offset,
radius: Float
) {
gradientState.centerFriction = center
gradientState.radiusFriction = radius
registerChanges()
}
fun setTileMode(tileMode: TileMode) {
gradientState.tileMode = tileMode
registerChanges()
}
fun setResolution(resolution: Float) {
meshGradientState.resolutionX = resolution.roundToInt()
meshGradientState.resolutionY = resolution.roundToInt()
registerChanges()
}
fun setScreenType(
type: GradientMakerType?
) {
_screenType.update { type }
}
fun addColorStop(
pair: Pair<Float, Color>,
isInitial: Boolean = false
) {
gradientState.colorStops.add(pair)
if (!isInitial) {
registerChanges()
}
}
fun updateColorStop(
index: Int,
pair: Pair<Float, Color>
) {
gradientState.colorStops[index] = pair.copy()
registerChanges()
}
fun removeColorStop(index: Int) {
if (gradientState.colorStops.size > 2) {
gradientState.colorStops.removeAt(index)
registerChanges()
}
}
fun updateSelectedUri(uri: Uri) {
componentScope.launch {
_selectedUri.value = uri
_colorPickerBitmap.value = null
_isImageLoading.value = true
imageGetter.getImageData(
uri = uri.toString(),
size = 2000,
onFailure = {
_isImageLoading.value = false
AppToastHost.showFailureToast(it)
}
)?.let { imageData ->
_colorPickerBitmap.value = imageData.image
_imageAspectRatio.update {
imageData.image.safeAspectRatio
}
_isImageLoading.value = false
setImageFormat(imageData.imageInfo.imageFormat)
}
}
}
fun updateGradientAlpha(value: Float) {
_gradientAlpha.update { value }
registerChanges()
}
override fun resetState() {
_selectedUri.update { Uri.EMPTY }
_colorPickerBitmap.value = null
_uris.update { emptyList() }
_gradientAlpha.update { 1f }
_gradientState = UiGradientState()
_meshGradientState = UiMeshGradientState()
setScreenType(null)
registerChangesCleared()
}
fun updateUrisSilently(
removedUri: Uri
) = componentScope.launch {
if (selectedUri == removedUri) {
val index = uris.indexOf(removedUri)
val replacementUri = if (index == 0) {
uris.getOrNull(1)
} else {
uris.getOrNull(index - 1)
}
if (replacementUri != null) {
updateSelectedUri(replacementUri)
} else {
_selectedUri.value = Uri.EMPTY
_colorPickerBitmap.value = null
}
}
_uris.update {
it.toMutableList().apply {
remove(removedUri)
}
}
}
fun setUris(uris: List<Uri>) {
_uris.update { uris }
uris.firstOrNull()?.let(::updateSelectedUri)
}
fun getGradientTransformation(): Transformation =
GenericTransformation<Bitmap>(
Triple(brush, meshPoints, screenType.isMesh())
) { input ->
createGradientBitmap(
data = input,
useBitmapOriginalSizeIfAvailable = false
) ?: input
}.toCoil()
fun toggleKeepExif(value: Boolean) {
_keepExif.update { value }
registerChanges()
}
fun cacheCurrentImage(onComplete: (Uri) -> Unit) {
_isSaving.value = false
savingJob?.cancel()
savingJob = trackProgress {
_isSaving.value = true
createGradientBitmap(
data = selectedUri,
useBitmapOriginalSizeIfAvailable = true
)?.let { image ->
shareProvider.cacheImage(
image = image,
imageInfo = ImageInfo(
imageFormat = imageFormat,
width = image.width,
height = image.height
)
)?.let { uri ->
onComplete(uri.toUri())
}
}
_isSaving.value = false
}
}
fun cacheImages(
onComplete: (List<Uri>) -> Unit
) {
savingJob = trackProgress {
val list = mutableListOf<Uri>()
_left.value = -1
_isSaving.value = true
if (uris.isEmpty()) {
createGradientBitmap(
data = Unit,
useBitmapOriginalSizeIfAvailable = true
)?.let { localBitmap ->
val imageInfo = ImageInfo(
imageFormat = imageFormat,
width = localBitmap.width,
height = localBitmap.height
)
shareProvider.cacheImage(
image = localBitmap,
imageInfo = imageInfo
)?.toUri()?.let(list::add)
}
} else {
_done.value = 0
_left.value = uris.size
uris.forEach { uri ->
createGradientBitmap(
data = uri,
useBitmapOriginalSizeIfAvailable = true
)?.let { localBitmap ->
val imageInfo = ImageInfo(
imageFormat = imageFormat,
width = localBitmap.width,
height = localBitmap.height,
originalUri = uri.toString()
)
shareProvider.cacheImage(
image = localBitmap,
imageInfo = imageInfo
)?.toUri()?.let(list::add)
}
_done.value += 1
updateProgress(
done = done,
total = left
)
}
}
_isSaving.value = false
onComplete(list)
_isSaving.value = false
}
}
fun selectLeftUri() {
uris
.indexOf(selectedUri)
.takeIf { it >= 0 }
?.let {
uris.leftFrom(it)
}
?.let(::updateSelectedUri)
}
fun selectRightUri() {
uris
.indexOf(selectedUri)
.takeIf { it >= 0 }
?.let {
uris.rightFrom(it)
}
?.let(::updateSelectedUri)
}
fun getFormatForFilenameSelection(): ImageFormat? = if (uris.size < 2) imageFormat
else null
fun setShowOriginal(value: Boolean) {
_showOriginal.update { value }
}
@AssistedFactory
fun interface Factory {
operator fun invoke(
componentContext: ComponentContext,
initialUris: List<Uri>?,
onGoBack: () -> Unit,
onNavigate: (Screen) -> Unit,
): GradientMakerComponent
}
}