chore: import upstream snapshot with attribution
This commit is contained in:
@@ -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>
|
||||
+574
@@ -0,0 +1,574 @@
|
||||
/*
|
||||
* 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.draw.data
|
||||
|
||||
import android.content.Context
|
||||
import android.graphics.Bitmap
|
||||
import android.graphics.BlurMaskFilter
|
||||
import android.graphics.Matrix
|
||||
import androidx.compose.ui.geometry.Offset
|
||||
import androidx.compose.ui.geometry.Size
|
||||
import androidx.compose.ui.graphics.BlendMode
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.ImageBitmap
|
||||
import androidx.compose.ui.graphics.ImageShader
|
||||
import androidx.compose.ui.graphics.Paint
|
||||
import androidx.compose.ui.graphics.PaintingStyle
|
||||
import androidx.compose.ui.graphics.Path
|
||||
import androidx.compose.ui.graphics.PathEffect
|
||||
import androidx.compose.ui.graphics.Shape
|
||||
import androidx.compose.ui.graphics.StampedPathEffectStyle
|
||||
import androidx.compose.ui.graphics.StrokeCap
|
||||
import androidx.compose.ui.graphics.StrokeJoin
|
||||
import androidx.compose.ui.graphics.addOutline
|
||||
import androidx.compose.ui.graphics.asAndroidBitmap
|
||||
import androidx.compose.ui.graphics.asAndroidPath
|
||||
import androidx.compose.ui.graphics.asComposePath
|
||||
import androidx.compose.ui.graphics.asImageBitmap
|
||||
import androidx.compose.ui.graphics.nativePaint
|
||||
import androidx.compose.ui.graphics.toArgb
|
||||
import androidx.compose.ui.unit.LayoutDirection
|
||||
import androidx.core.graphics.applyCanvas
|
||||
import androidx.core.graphics.createBitmap
|
||||
import androidx.core.graphics.drawable.toBitmap
|
||||
import androidx.core.graphics.drawable.toDrawable
|
||||
import com.t8rin.imagetoolbox.core.data.image.utils.drawBitmap
|
||||
import com.t8rin.imagetoolbox.core.data.utils.density
|
||||
import com.t8rin.imagetoolbox.core.data.utils.safeConfig
|
||||
import com.t8rin.imagetoolbox.core.data.utils.toSoftware
|
||||
import com.t8rin.imagetoolbox.core.domain.image.ImageGetter
|
||||
import com.t8rin.imagetoolbox.core.domain.image.ImageTransformer
|
||||
import com.t8rin.imagetoolbox.core.domain.model.ImageModel
|
||||
import com.t8rin.imagetoolbox.core.domain.model.IntegerSize
|
||||
import com.t8rin.imagetoolbox.core.domain.model.max
|
||||
import com.t8rin.imagetoolbox.core.domain.transformation.Transformation
|
||||
import com.t8rin.imagetoolbox.core.filters.domain.FilterProvider
|
||||
import com.t8rin.imagetoolbox.core.filters.domain.model.Filter
|
||||
import com.t8rin.imagetoolbox.core.filters.domain.model.createFilter
|
||||
import com.t8rin.imagetoolbox.core.filters.domain.model.enums.SpotHealMode
|
||||
import com.t8rin.imagetoolbox.core.resources.shapes.MaterialStarShape
|
||||
import com.t8rin.imagetoolbox.core.utils.toImageModel
|
||||
import com.t8rin.imagetoolbox.core.utils.toTypeface
|
||||
import com.t8rin.imagetoolbox.feature.draw.data.utils.drawRepeatedBitmapOnPath
|
||||
import com.t8rin.imagetoolbox.feature.draw.data.utils.drawRepeatedTextOnPath
|
||||
import com.t8rin.imagetoolbox.feature.draw.domain.DrawBehavior
|
||||
import com.t8rin.imagetoolbox.feature.draw.domain.DrawLineStyle
|
||||
import com.t8rin.imagetoolbox.feature.draw.domain.DrawMode
|
||||
import com.t8rin.imagetoolbox.feature.draw.domain.DrawPathMode
|
||||
import com.t8rin.imagetoolbox.feature.draw.domain.ImageDrawApplier
|
||||
import com.t8rin.imagetoolbox.feature.draw.domain.PathPaint
|
||||
import com.t8rin.trickle.WarpBrush
|
||||
import com.t8rin.trickle.WarpEngine
|
||||
import com.t8rin.trickle.WarpMode
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import javax.inject.Inject
|
||||
import kotlin.math.roundToInt
|
||||
import android.graphics.Paint as AndroidPaint
|
||||
import android.graphics.Path as NativePath
|
||||
|
||||
internal class AndroidImageDrawApplier @Inject constructor(
|
||||
@ApplicationContext private val context: Context,
|
||||
private val imageTransformer: ImageTransformer<Bitmap>,
|
||||
private val imageGetter: ImageGetter<Bitmap>,
|
||||
private val filterProvider: FilterProvider<Bitmap>,
|
||||
) : ImageDrawApplier<Bitmap, Path, Color> {
|
||||
|
||||
override suspend fun applyDrawToImage(
|
||||
drawBehavior: DrawBehavior,
|
||||
pathPaints: List<PathPaint<Path, Color>>,
|
||||
imageUri: String
|
||||
): Bitmap? {
|
||||
val image: Bitmap? = when (drawBehavior) {
|
||||
is DrawBehavior.Image -> {
|
||||
imageGetter.getImage(data = imageUri)
|
||||
}
|
||||
|
||||
is DrawBehavior.Background -> {
|
||||
drawBehavior.color.toDrawable().toBitmap(
|
||||
width = drawBehavior.width.coerceAtLeast(1),
|
||||
height = drawBehavior.height.coerceAtLeast(1)
|
||||
)
|
||||
}
|
||||
|
||||
else -> null
|
||||
}
|
||||
|
||||
val drawImage = image?.let {
|
||||
createBitmap(it.width, it.height, it.safeConfig).apply { setHasAlpha(true) }
|
||||
}
|
||||
|
||||
drawImage?.let { bitmap ->
|
||||
bitmap.applyCanvas {
|
||||
val canvasSize = IntegerSize(width, height)
|
||||
|
||||
(drawBehavior as? DrawBehavior.Background)?.apply { drawColor(color) }
|
||||
|
||||
pathPaints.forEach { (nonScaledPath, nonScaledStroke, radius, drawColor, isErasing, drawMode, size, drawPathMode, drawLineStyle) ->
|
||||
val stroke = drawPathMode.convertStrokeWidth(
|
||||
strokeWidth = nonScaledStroke,
|
||||
canvasSize = canvasSize
|
||||
)
|
||||
val path = nonScaledPath.scaleToFitCanvas(
|
||||
currentSize = canvasSize,
|
||||
oldSize = size
|
||||
)
|
||||
val isSharpEdge = drawPathMode.isSharpEdge
|
||||
val isFilled = drawPathMode.isFilled
|
||||
|
||||
if (drawMode is DrawMode.PathEffect && !isErasing) {
|
||||
val paint = Paint().apply {
|
||||
if (isFilled) {
|
||||
style = PaintingStyle.Fill
|
||||
} else {
|
||||
style = PaintingStyle.Stroke
|
||||
this.strokeWidth = stroke
|
||||
if (isSharpEdge) {
|
||||
strokeCap = StrokeCap.Square
|
||||
} else {
|
||||
strokeCap = StrokeCap.Round
|
||||
strokeJoin = StrokeJoin.Round
|
||||
}
|
||||
}
|
||||
|
||||
color = Color.Transparent
|
||||
blendMode = BlendMode.Clear
|
||||
}
|
||||
|
||||
val shaderSource = imageTransformer.transform(
|
||||
image = image.overlay(bitmap),
|
||||
transformations = transformationsForMode(
|
||||
canvasSize = canvasSize,
|
||||
drawMode = drawMode
|
||||
)
|
||||
)?.asImageBitmap()?.clipBitmap(
|
||||
path = path,
|
||||
paint = paint
|
||||
)
|
||||
if (shaderSource != null) {
|
||||
drawBitmap(shaderSource.asAndroidBitmap())
|
||||
}
|
||||
} else if (drawMode is DrawMode.SpotHeal && !isErasing) {
|
||||
val paint = Paint().apply {
|
||||
if (isFilled) {
|
||||
style = PaintingStyle.Fill
|
||||
} else {
|
||||
style = PaintingStyle.Stroke
|
||||
this.strokeWidth = stroke
|
||||
if (isSharpEdge) {
|
||||
strokeCap = StrokeCap.Square
|
||||
} else {
|
||||
strokeCap = StrokeCap.Round
|
||||
strokeJoin = StrokeJoin.Round
|
||||
}
|
||||
}
|
||||
|
||||
color = Color.White
|
||||
}
|
||||
|
||||
val filter = filterProvider.filterToTransformation(
|
||||
createFilter<Pair<ImageModel, SpotHealMode>, Filter.SpotHeal>(
|
||||
Pair(
|
||||
createBitmap(
|
||||
canvasSize.width,
|
||||
canvasSize.height
|
||||
).applyCanvas {
|
||||
drawColor(Color.Black.toArgb())
|
||||
drawPath(
|
||||
path.asAndroidPath(),
|
||||
paint.nativePaint
|
||||
)
|
||||
}.toImageModel(),
|
||||
drawMode.mode
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
imageTransformer.transform(
|
||||
image = image.overlay(bitmap),
|
||||
transformations = listOf(filter)
|
||||
)?.let {
|
||||
drawBitmap(
|
||||
it.asImageBitmap().clipBitmap(
|
||||
path = path,
|
||||
paint = paint.apply {
|
||||
blendMode = BlendMode.Clear
|
||||
}
|
||||
).asAndroidBitmap()
|
||||
)
|
||||
}
|
||||
} else if (drawMode is DrawMode.Warp && !isErasing) {
|
||||
val engine = WarpEngine(image.overlay(bitmap))
|
||||
|
||||
try {
|
||||
drawMode.strokes.forEach { warpStroke ->
|
||||
val warp = warpStroke.scaleToFitCanvas(
|
||||
currentSize = canvasSize,
|
||||
oldSize = size
|
||||
)
|
||||
engine.applyStroke(
|
||||
fromX = warp.fromX,
|
||||
fromY = warp.fromY,
|
||||
toX = warp.toX,
|
||||
toY = warp.toY,
|
||||
brush = WarpBrush(
|
||||
radius = stroke,
|
||||
strength = drawMode.strength,
|
||||
hardness = drawMode.hardness
|
||||
),
|
||||
mode = WarpMode.valueOf(drawMode.warpMode.name)
|
||||
)
|
||||
}
|
||||
|
||||
drawBitmap(engine.render())
|
||||
} finally {
|
||||
engine.release()
|
||||
}
|
||||
} else {
|
||||
val paint = Paint().apply {
|
||||
if (isErasing) {
|
||||
blendMode = BlendMode.Clear
|
||||
style = PaintingStyle.Stroke
|
||||
this.strokeWidth = stroke
|
||||
strokeCap = StrokeCap.Round
|
||||
strokeJoin = StrokeJoin.Round
|
||||
} else {
|
||||
if (drawMode !is DrawMode.Text) {
|
||||
pathEffect = drawLineStyle.asPathEffect(
|
||||
canvasSize = canvasSize,
|
||||
strokeWidth = stroke
|
||||
)
|
||||
if (isFilled) {
|
||||
style = PaintingStyle.Fill
|
||||
} else {
|
||||
style = PaintingStyle.Stroke
|
||||
strokeWidth = stroke
|
||||
if (drawMode is DrawMode.Highlighter || isSharpEdge) {
|
||||
strokeCap = StrokeCap.Square
|
||||
} else {
|
||||
strokeCap = StrokeCap.Round
|
||||
strokeJoin = StrokeJoin.Round
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
color = drawColor
|
||||
alpha = drawColor.alpha
|
||||
}.nativePaint.apply {
|
||||
if (drawMode is DrawMode.Neon && !isErasing) {
|
||||
this.color = Color.White.toArgb()
|
||||
setShadowLayer(
|
||||
radius.toPx(canvasSize),
|
||||
0f,
|
||||
0f,
|
||||
drawColor
|
||||
.copy(alpha = .8f)
|
||||
.toArgb()
|
||||
)
|
||||
} else if (radius.value > 0f) {
|
||||
maskFilter =
|
||||
BlurMaskFilter(
|
||||
radius.toPx(canvasSize),
|
||||
BlurMaskFilter.Blur.NORMAL
|
||||
)
|
||||
}
|
||||
if (drawMode is DrawMode.Text && !isErasing) {
|
||||
isAntiAlias = true
|
||||
textSize = stroke
|
||||
typeface = drawMode.font.toTypeface()
|
||||
}
|
||||
}
|
||||
val androidPath = path.asAndroidPath()
|
||||
if (drawMode is DrawMode.Text && !isErasing) {
|
||||
if (drawMode.isRepeated) {
|
||||
drawRepeatedTextOnPath(
|
||||
text = drawMode.text,
|
||||
path = androidPath,
|
||||
paint = paint,
|
||||
interval = drawMode.repeatingInterval.toPx(canvasSize)
|
||||
)
|
||||
} else {
|
||||
drawTextOnPath(drawMode.text, androidPath, 0f, 0f, paint)
|
||||
}
|
||||
} else if (drawMode is DrawMode.Image && !isErasing) {
|
||||
imageGetter.getImage(
|
||||
data = drawMode.imageData,
|
||||
size = stroke.roundToInt()
|
||||
)?.let {
|
||||
drawRepeatedBitmapOnPath(
|
||||
bitmap = it,
|
||||
path = androidPath,
|
||||
paint = paint,
|
||||
interval = drawMode.repeatingInterval.toPx(canvasSize)
|
||||
)
|
||||
}
|
||||
} else if (drawPathMode is DrawPathMode.Outlined) {
|
||||
drawPathMode.fillColor?.let { fillColor ->
|
||||
val filledPaint = AndroidPaint().apply {
|
||||
set(paint)
|
||||
style = AndroidPaint.Style.FILL
|
||||
color = fillColor.colorInt
|
||||
if (Color(fillColor.colorInt).alpha == 1f) {
|
||||
alpha =
|
||||
(drawColor.alpha * 255).roundToInt().coerceIn(0, 255)
|
||||
}
|
||||
pathEffect = null
|
||||
}
|
||||
|
||||
drawPath(androidPath, filledPaint)
|
||||
}
|
||||
drawPath(androidPath, paint)
|
||||
} else {
|
||||
drawPath(androidPath, paint)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return drawImage?.let { image.overlay(it) }
|
||||
}
|
||||
|
||||
override suspend fun applyEraseToImage(
|
||||
pathPaints: List<PathPaint<Path, Color>>,
|
||||
imageUri: String
|
||||
): Bitmap? = applyEraseToImage(
|
||||
pathPaints = pathPaints,
|
||||
image = imageGetter.getImage(data = imageUri),
|
||||
shaderSourceUri = imageUri
|
||||
)
|
||||
|
||||
override suspend fun applyEraseToImage(
|
||||
pathPaints: List<PathPaint<Path, Color>>,
|
||||
image: Bitmap?,
|
||||
shaderSourceUri: String
|
||||
): Bitmap? = image?.let { it.copy(it.safeConfig, true) }?.let { bitmap ->
|
||||
bitmap.applyCanvas {
|
||||
val canvasSize = IntegerSize(width, height)
|
||||
|
||||
drawBitmap(bitmap)
|
||||
|
||||
val recoveryShader = imageGetter.getImage(
|
||||
data = shaderSourceUri
|
||||
)?.asImageBitmap()?.let { bmp -> ImageShader(bmp) }
|
||||
|
||||
pathPaints.forEach { (nonScaledPath, stroke, radius, _, isRecoveryOn, _, size, mode) ->
|
||||
val path = nonScaledPath.scaleToFitCanvas(
|
||||
currentSize = canvasSize,
|
||||
oldSize = size
|
||||
)
|
||||
|
||||
drawPath(
|
||||
path.asAndroidPath(),
|
||||
Paint().apply {
|
||||
if (mode.isFilled) {
|
||||
style = PaintingStyle.Fill
|
||||
} else {
|
||||
style = PaintingStyle.Stroke
|
||||
this.strokeWidth = mode.convertStrokeWidth(
|
||||
strokeWidth = stroke,
|
||||
canvasSize = canvasSize
|
||||
)
|
||||
if (mode.isSharpEdge) {
|
||||
strokeCap = StrokeCap.Square
|
||||
} else {
|
||||
strokeCap = StrokeCap.Round
|
||||
strokeJoin = StrokeJoin.Round
|
||||
}
|
||||
}
|
||||
if (isRecoveryOn) {
|
||||
shader = recoveryShader
|
||||
} else {
|
||||
blendMode = BlendMode.Clear
|
||||
}
|
||||
}.nativePaint.apply {
|
||||
if (radius.value > 0f) {
|
||||
maskFilter =
|
||||
BlurMaskFilter(
|
||||
radius.toPx(canvasSize),
|
||||
BlurMaskFilter.Blur.NORMAL
|
||||
)
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
private fun transformationsForMode(
|
||||
canvasSize: IntegerSize,
|
||||
drawMode: DrawMode
|
||||
): List<Transformation<Bitmap>> = when (drawMode) {
|
||||
is DrawMode.PathEffect.PrivacyBlur -> {
|
||||
listOf(
|
||||
createFilter<Float, Filter.NativeStackBlur>(
|
||||
drawMode.blurRadius.toFloat() / 1000 * max(canvasSize)
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
is DrawMode.PathEffect.Pixelation -> {
|
||||
listOf(
|
||||
createFilter<Float, Filter.NativeStackBlur>(
|
||||
20.toFloat() / 1000 * max(canvasSize)
|
||||
),
|
||||
createFilter<Float, Filter.Pixelation>(
|
||||
drawMode.pixelSize / 1000 * max(canvasSize)
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
is DrawMode.PathEffect.Custom -> drawMode.filter?.let {
|
||||
listOf(it)
|
||||
} ?: emptyList()
|
||||
|
||||
else -> emptyList()
|
||||
}.map {
|
||||
filterProvider.filterToTransformation(it)
|
||||
}
|
||||
|
||||
private fun DrawLineStyle.asPathEffect(
|
||||
canvasSize: IntegerSize,
|
||||
strokeWidth: Float
|
||||
): PathEffect? = when (this) {
|
||||
is DrawLineStyle.Dashed -> {
|
||||
PathEffect.dashPathEffect(
|
||||
intervals = floatArrayOf(
|
||||
size.toPx(canvasSize),
|
||||
gap.toPx(canvasSize) + strokeWidth
|
||||
),
|
||||
phase = 0f
|
||||
)
|
||||
}
|
||||
|
||||
DrawLineStyle.DotDashed -> {
|
||||
val dashOnInterval1 = strokeWidth * 4
|
||||
val dashOffInterval1 = strokeWidth * 2
|
||||
val dashOnInterval2 = strokeWidth / 4
|
||||
val dashOffInterval2 = strokeWidth * 2
|
||||
|
||||
PathEffect.dashPathEffect(
|
||||
intervals = floatArrayOf(
|
||||
dashOnInterval1,
|
||||
dashOffInterval1,
|
||||
dashOnInterval2,
|
||||
dashOffInterval2
|
||||
),
|
||||
phase = 0f
|
||||
)
|
||||
}
|
||||
|
||||
is DrawLineStyle.Stamped<*> -> {
|
||||
fun Shape.toPath(): Path = Path().apply {
|
||||
addOutline(
|
||||
createOutline(
|
||||
size = Size(strokeWidth, strokeWidth),
|
||||
layoutDirection = LayoutDirection.Ltr,
|
||||
density = context.density
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
val path: Path? = when (shape) {
|
||||
is Shape -> shape.toPath()
|
||||
is NativePath -> shape.asComposePath()
|
||||
is Path -> shape
|
||||
null -> MaterialStarShape.toPath()
|
||||
else -> null
|
||||
}
|
||||
|
||||
path?.let {
|
||||
PathEffect.stampedPathEffect(
|
||||
shape = it,
|
||||
advance = spacing.toPx(canvasSize) + strokeWidth,
|
||||
phase = 0f,
|
||||
style = StampedPathEffectStyle.Morph
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
is DrawLineStyle.ZigZag -> {
|
||||
val zigZagPath = Path().apply {
|
||||
val zigZagLineWidth = strokeWidth / heightRatio
|
||||
val shapeVerticalOffset = (strokeWidth / 2) / 2
|
||||
val shapeHorizontalOffset = (strokeWidth / 2) / 2
|
||||
moveTo(0f, 0f)
|
||||
lineTo(strokeWidth / 2, strokeWidth / 2)
|
||||
lineTo(strokeWidth, 0f)
|
||||
lineTo(strokeWidth, 0f + zigZagLineWidth)
|
||||
lineTo(strokeWidth / 2, strokeWidth / 2 + zigZagLineWidth)
|
||||
lineTo(0f, 0f + zigZagLineWidth)
|
||||
translate(Offset(-shapeHorizontalOffset, -shapeVerticalOffset))
|
||||
}
|
||||
PathEffect.stampedPathEffect(
|
||||
shape = zigZagPath,
|
||||
advance = strokeWidth,
|
||||
phase = 0f,
|
||||
style = StampedPathEffectStyle.Morph
|
||||
)
|
||||
}
|
||||
|
||||
DrawLineStyle.None -> null
|
||||
}
|
||||
|
||||
private fun ImageBitmap.clipBitmap(
|
||||
path: Path,
|
||||
paint: Paint,
|
||||
): ImageBitmap {
|
||||
val newPath = NativePath(path.asAndroidPath())
|
||||
|
||||
return asAndroidBitmap().copy(Bitmap.Config.ARGB_8888, true).applyCanvas {
|
||||
drawPath(
|
||||
newPath.apply {
|
||||
fillType = NativePath.FillType.INVERSE_WINDING
|
||||
},
|
||||
paint.nativePaint
|
||||
)
|
||||
}.asImageBitmap()
|
||||
}
|
||||
|
||||
private fun Bitmap.overlay(overlay: Bitmap): Bitmap {
|
||||
val image = this
|
||||
|
||||
return createBitmap(
|
||||
width = image.width,
|
||||
height = image.height,
|
||||
config = safeConfig.toSoftware()
|
||||
).applyCanvas {
|
||||
drawBitmap(image)
|
||||
drawBitmap(overlay.toSoftware())
|
||||
}
|
||||
}
|
||||
|
||||
private fun Path.scaleToFitCanvas(
|
||||
currentSize: IntegerSize,
|
||||
oldSize: IntegerSize,
|
||||
onGetScale: (Float, Float) -> Unit = { _, _ -> }
|
||||
): Path {
|
||||
val sx = currentSize.width.toFloat() / oldSize.width
|
||||
val sy = currentSize.height.toFloat() / oldSize.height
|
||||
onGetScale(sx, sy)
|
||||
return NativePath(this.asAndroidPath()).apply {
|
||||
transform(
|
||||
Matrix().apply {
|
||||
setScale(sx, sy)
|
||||
}
|
||||
)
|
||||
}.asComposePath()
|
||||
}
|
||||
|
||||
}
|
||||
+97
@@ -0,0 +1,97 @@
|
||||
/*
|
||||
* 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.draw.data.utils
|
||||
|
||||
import android.graphics.Bitmap
|
||||
import android.graphics.Canvas
|
||||
import android.graphics.Matrix
|
||||
import android.graphics.Paint
|
||||
import android.graphics.Path
|
||||
import android.graphics.PathMeasure
|
||||
import androidx.core.graphics.withSave
|
||||
import kotlin.math.atan2
|
||||
|
||||
fun Canvas.drawRepeatedTextOnPath(
|
||||
text: String,
|
||||
path: Path,
|
||||
paint: Paint,
|
||||
interval: Float = 0f
|
||||
) {
|
||||
val pathMeasure = PathMeasure(path, false)
|
||||
val pathLength = pathMeasure.length
|
||||
|
||||
val textWidth = paint.measureText(text)
|
||||
|
||||
val fullRepeats = (pathLength / (textWidth + interval)).toInt()
|
||||
|
||||
val remainingLength = pathLength - fullRepeats * (textWidth + interval)
|
||||
|
||||
var distance = 0f
|
||||
|
||||
repeat(fullRepeats) {
|
||||
drawTextOnPath(text, path, distance, 0f, paint)
|
||||
distance += (textWidth + interval)
|
||||
}
|
||||
|
||||
if (remainingLength > 0f) {
|
||||
val ratio = (textWidth + interval - (remainingLength)) / (textWidth + interval)
|
||||
val endOffset = (text.length - (text.length * ratio).toInt()).coerceAtLeast(0)
|
||||
drawTextOnPath(text.substring(0, endOffset), path, distance, 0f, paint)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
fun Canvas.drawRepeatedBitmapOnPath(
|
||||
bitmap: Bitmap,
|
||||
path: Path,
|
||||
paint: Paint,
|
||||
interval: Float = 0f
|
||||
) {
|
||||
val pathMeasure = PathMeasure(path, false)
|
||||
val pathLength = pathMeasure.length
|
||||
|
||||
val bitmapWidth = bitmap.width.toFloat()
|
||||
val bitmapHeight = bitmap.height.toFloat()
|
||||
|
||||
var distance = 0f
|
||||
val matrix = Matrix()
|
||||
|
||||
while (distance < pathLength) {
|
||||
val pos = FloatArray(2)
|
||||
val tan = FloatArray(2)
|
||||
pathMeasure.getPosTan(distance, pos, tan)
|
||||
|
||||
val degree = Math.toDegrees(atan2(tan[1].toDouble(), tan[0].toDouble())).toFloat()
|
||||
|
||||
withSave {
|
||||
translate(pos[0], pos[1])
|
||||
rotate(degree)
|
||||
|
||||
matrix.reset()
|
||||
matrix.postTranslate(-bitmapWidth / 2, -bitmapHeight / 2)
|
||||
matrix.postRotate(degree)
|
||||
matrix.postTranslate(bitmapWidth / 2, bitmapHeight / 2)
|
||||
drawBitmap(bitmap, matrix, paint)
|
||||
}
|
||||
|
||||
if (interval < 0 && distance + bitmapWidth < 0) break
|
||||
else {
|
||||
distance += (bitmapWidth + interval)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
/*
|
||||
* 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.draw.di
|
||||
|
||||
import android.graphics.Bitmap
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.Path
|
||||
import com.t8rin.imagetoolbox.feature.draw.data.AndroidImageDrawApplier
|
||||
import com.t8rin.imagetoolbox.feature.draw.domain.ImageDrawApplier
|
||||
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 DrawModule {
|
||||
|
||||
@Singleton
|
||||
@Binds
|
||||
fun provideImageDrawApplier(
|
||||
applier: AndroidImageDrawApplier
|
||||
): ImageDrawApplier<Bitmap, Path, Color>
|
||||
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
/*
|
||||
* 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.draw.domain
|
||||
|
||||
sealed class DrawBehavior(
|
||||
open val orientation: Int
|
||||
) {
|
||||
data object None : DrawBehavior(2)
|
||||
|
||||
data class Image(
|
||||
override val orientation: Int
|
||||
) : DrawBehavior(orientation = orientation)
|
||||
|
||||
data class Background(
|
||||
override val orientation: Int,
|
||||
val width: Int,
|
||||
val height: Int,
|
||||
val color: Int
|
||||
) : DrawBehavior(orientation = orientation)
|
||||
}
|
||||
+62
@@ -0,0 +1,62 @@
|
||||
/*
|
||||
* 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.draw.domain
|
||||
|
||||
import com.t8rin.imagetoolbox.core.domain.model.Pt
|
||||
import com.t8rin.imagetoolbox.core.domain.model.pt
|
||||
|
||||
// Works only for Basic draw modes (excludes DrawMode.PathEffect, DrawMode.SpotHeal, DrawMode.Text, DrawMode.Image)
|
||||
sealed class DrawLineStyle(
|
||||
val ordinal: Int
|
||||
) {
|
||||
data object None : DrawLineStyle(0)
|
||||
|
||||
data class Dashed(
|
||||
val size: Pt = 10.pt,
|
||||
val gap: Pt = 20.pt
|
||||
) : DrawLineStyle(1)
|
||||
|
||||
data class ZigZag(
|
||||
val heightRatio: Float = 4f
|
||||
) : DrawLineStyle(2)
|
||||
|
||||
data class Stamped<Shape>(
|
||||
val shape: Shape? = null,
|
||||
val spacing: Pt = 20.pt
|
||||
) : DrawLineStyle(3)
|
||||
|
||||
data object DotDashed : DrawLineStyle(4)
|
||||
|
||||
companion object {
|
||||
val entries by lazy {
|
||||
listOf(
|
||||
None,
|
||||
Dashed(),
|
||||
DotDashed,
|
||||
ZigZag(),
|
||||
Stamped<Any>(),
|
||||
)
|
||||
}
|
||||
|
||||
fun fromOrdinal(
|
||||
ordinal: Int
|
||||
): DrawLineStyle = entries.find {
|
||||
it.ordinal == ordinal
|
||||
} ?: None
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
/*
|
||||
* 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.draw.domain
|
||||
|
||||
import com.t8rin.imagetoolbox.core.domain.model.Pt
|
||||
import com.t8rin.imagetoolbox.core.filters.domain.model.Filter
|
||||
import com.t8rin.imagetoolbox.core.filters.domain.model.enums.SpotHealMode
|
||||
import com.t8rin.imagetoolbox.core.settings.domain.model.FontType
|
||||
|
||||
sealed class DrawMode(open val ordinal: Int) {
|
||||
data object Neon : DrawMode(2)
|
||||
data object Highlighter : DrawMode(3)
|
||||
data object Pen : DrawMode(0)
|
||||
|
||||
sealed class PathEffect(override val ordinal: Int) : DrawMode(ordinal) {
|
||||
data class PrivacyBlur(
|
||||
val blurRadius: Int = 20
|
||||
) : PathEffect(1)
|
||||
|
||||
data class Pixelation(
|
||||
val pixelSize: Float = 35f
|
||||
) : PathEffect(4)
|
||||
|
||||
data class Custom(
|
||||
val filter: Filter<*>? = null
|
||||
) : PathEffect(5)
|
||||
}
|
||||
|
||||
data class Text(
|
||||
val text: String = "Text",
|
||||
val font: FontType? = null,
|
||||
val isRepeated: Boolean = false,
|
||||
val repeatingInterval: Pt = Pt.Zero
|
||||
) : DrawMode(6)
|
||||
|
||||
data class Image(
|
||||
val imageData: Any = "file:///android_asset/svg/emotions/aasparkles.svg",
|
||||
val repeatingInterval: Pt = Pt.Zero
|
||||
) : DrawMode(7)
|
||||
|
||||
data class SpotHeal(
|
||||
val mode: SpotHealMode = SpotHealMode.OpenCV
|
||||
) : DrawMode(8)
|
||||
|
||||
data class Warp(
|
||||
val warpMode: WarpMode = WarpMode.MOVE,
|
||||
val strength: Float = 0.25f,
|
||||
val hardness: Float = 0.5f,
|
||||
val strokes: List<WarpStroke> = emptyList(),
|
||||
val previewClearToken: Long = 0L
|
||||
) : DrawMode(9)
|
||||
|
||||
companion object {
|
||||
val entries by lazy {
|
||||
listOf(
|
||||
Pen,
|
||||
PathEffect.PrivacyBlur(),
|
||||
SpotHeal(),
|
||||
Warp(),
|
||||
Neon,
|
||||
Highlighter,
|
||||
Text(),
|
||||
Image(),
|
||||
PathEffect.Pixelation(),
|
||||
PathEffect.Custom()
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
+34
@@ -0,0 +1,34 @@
|
||||
/*
|
||||
* 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.draw.domain
|
||||
|
||||
data class DrawOnBackgroundParams(
|
||||
val width: Int,
|
||||
val height: Int,
|
||||
val color: Int?,
|
||||
) {
|
||||
companion object {
|
||||
val Default by lazy {
|
||||
DrawOnBackgroundParams(
|
||||
width = -1,
|
||||
height = -1,
|
||||
color = null
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
+212
@@ -0,0 +1,212 @@
|
||||
/*
|
||||
* 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.draw.domain
|
||||
|
||||
import com.t8rin.imagetoolbox.core.domain.model.ColorModel
|
||||
import com.t8rin.imagetoolbox.core.domain.model.IntegerSize
|
||||
import com.t8rin.imagetoolbox.core.domain.model.Pt
|
||||
import com.t8rin.imagetoolbox.core.domain.model.pt
|
||||
import com.t8rin.imagetoolbox.core.domain.utils.safeCast
|
||||
import com.t8rin.imagetoolbox.feature.draw.domain.DrawPathMode.FloodFill
|
||||
import com.t8rin.imagetoolbox.feature.draw.domain.DrawPathMode.Lasso
|
||||
import com.t8rin.imagetoolbox.feature.draw.domain.DrawPathMode.OutlinedOval
|
||||
import com.t8rin.imagetoolbox.feature.draw.domain.DrawPathMode.OutlinedRect
|
||||
import com.t8rin.imagetoolbox.feature.draw.domain.DrawPathMode.Oval
|
||||
import com.t8rin.imagetoolbox.feature.draw.domain.DrawPathMode.Polygon
|
||||
import com.t8rin.imagetoolbox.feature.draw.domain.DrawPathMode.Rect
|
||||
import com.t8rin.imagetoolbox.feature.draw.domain.DrawPathMode.Spray
|
||||
import com.t8rin.imagetoolbox.feature.draw.domain.DrawPathMode.Star
|
||||
import com.t8rin.imagetoolbox.feature.draw.domain.DrawPathMode.Triangle
|
||||
|
||||
sealed class DrawPathMode(
|
||||
val ordinal: Int
|
||||
) {
|
||||
sealed class Outlined(ordinal: Int) : DrawPathMode(ordinal) {
|
||||
abstract val fillColor: ColorModel?
|
||||
}
|
||||
|
||||
data object Free : DrawPathMode(0)
|
||||
data object Line : DrawPathMode(1)
|
||||
|
||||
data class PointingArrow(
|
||||
val sizeScale: Float = 3f,
|
||||
val angle: Float = 150f
|
||||
) : DrawPathMode(2)
|
||||
|
||||
data class DoublePointingArrow(
|
||||
val sizeScale: Float = 3f,
|
||||
val angle: Float = 150f
|
||||
) : DrawPathMode(3)
|
||||
|
||||
data class LinePointingArrow(
|
||||
val sizeScale: Float = 3f,
|
||||
val angle: Float = 150f
|
||||
) : DrawPathMode(4)
|
||||
|
||||
data class DoubleLinePointingArrow(
|
||||
val sizeScale: Float = 3f,
|
||||
val angle: Float = 150f
|
||||
) : DrawPathMode(5)
|
||||
|
||||
data object Lasso : DrawPathMode(6)
|
||||
|
||||
data class OutlinedRect(
|
||||
val rotationDegrees: Int = 0,
|
||||
val cornerRadius: Float = 0f,
|
||||
override val fillColor: ColorModel? = null
|
||||
) : Outlined(7)
|
||||
|
||||
data class OutlinedOval(
|
||||
override val fillColor: ColorModel? = null
|
||||
) : Outlined(8)
|
||||
|
||||
data class Rect(
|
||||
val rotationDegrees: Int = 0,
|
||||
val cornerRadius: Float = 0f
|
||||
) : DrawPathMode(9)
|
||||
|
||||
data object Oval : DrawPathMode(10)
|
||||
data object Triangle : DrawPathMode(11)
|
||||
data class OutlinedTriangle(
|
||||
override val fillColor: ColorModel? = null
|
||||
) : Outlined(12)
|
||||
|
||||
data class Polygon(
|
||||
val vertices: Int = 5,
|
||||
val rotationDegrees: Int = 0,
|
||||
val isRegular: Boolean = false
|
||||
) : DrawPathMode(13)
|
||||
|
||||
data class OutlinedPolygon(
|
||||
val vertices: Int = 5,
|
||||
val rotationDegrees: Int = 0,
|
||||
val isRegular: Boolean = false,
|
||||
override val fillColor: ColorModel? = null
|
||||
) : Outlined(14)
|
||||
|
||||
data class Star(
|
||||
val vertices: Int = 5,
|
||||
val rotationDegrees: Int = 0,
|
||||
val innerRadiusRatio: Float = 0.5f,
|
||||
val isRegular: Boolean = false
|
||||
) : DrawPathMode(15)
|
||||
|
||||
data class OutlinedStar(
|
||||
val vertices: Int = 5,
|
||||
val rotationDegrees: Int = 0,
|
||||
val innerRadiusRatio: Float = 0.5f,
|
||||
val isRegular: Boolean = false,
|
||||
override val fillColor: ColorModel? = null
|
||||
) : Outlined(16)
|
||||
|
||||
data class FloodFill(
|
||||
val tolerance: Float = 0.25f
|
||||
) : DrawPathMode(17) {
|
||||
companion object {
|
||||
val StrokeSize = 2f.pt
|
||||
}
|
||||
}
|
||||
|
||||
data class Spray(
|
||||
val density: Int = 50,
|
||||
val pixelSize: Float = 1f,
|
||||
val isSquareShaped: Boolean = false
|
||||
) : DrawPathMode(18)
|
||||
|
||||
val canChangeStrokeWidth: Boolean
|
||||
get() = this !is FloodFill && (!isFilled || this is Spray)
|
||||
|
||||
val isFilled: Boolean
|
||||
get() = filled.any { this::class.isInstance(it) }
|
||||
|
||||
val outlinedFillColor: ColorModel?
|
||||
get() = this.safeCast<Outlined>()?.fillColor
|
||||
|
||||
val isSharpEdge: Boolean
|
||||
get() = sharp.any { this::class.isInstance(it) }
|
||||
|
||||
companion object {
|
||||
val entries by lazy {
|
||||
listOf(
|
||||
Free,
|
||||
FloodFill(),
|
||||
Spray(),
|
||||
Line,
|
||||
PointingArrow(),
|
||||
DoublePointingArrow(),
|
||||
LinePointingArrow(),
|
||||
DoubleLinePointingArrow(),
|
||||
Lasso,
|
||||
OutlinedRect(),
|
||||
OutlinedOval(),
|
||||
OutlinedTriangle(),
|
||||
OutlinedPolygon(),
|
||||
OutlinedStar(),
|
||||
Rect(),
|
||||
Oval,
|
||||
Triangle,
|
||||
Polygon(),
|
||||
Star()
|
||||
)
|
||||
}
|
||||
|
||||
val outlinedEntries by lazy {
|
||||
listOf(
|
||||
OutlinedRect(),
|
||||
OutlinedOval(),
|
||||
OutlinedTriangle(),
|
||||
OutlinedPolygon(),
|
||||
OutlinedStar()
|
||||
)
|
||||
}
|
||||
|
||||
fun fromOrdinal(
|
||||
ordinal: Int
|
||||
): DrawPathMode = entries.find {
|
||||
it.ordinal == ordinal
|
||||
} ?: Free
|
||||
}
|
||||
|
||||
fun convertStrokeWidth(
|
||||
strokeWidth: Pt,
|
||||
canvasSize: IntegerSize
|
||||
): Float = when (this) {
|
||||
is FloodFill -> FloodFill.StrokeSize.toPx(canvasSize)
|
||||
else -> strokeWidth.toPx(canvasSize)
|
||||
}
|
||||
}
|
||||
|
||||
private val filled = listOf(
|
||||
Lasso,
|
||||
Rect(),
|
||||
Oval,
|
||||
Triangle,
|
||||
Polygon(),
|
||||
Star(),
|
||||
Spray()
|
||||
)
|
||||
|
||||
private val sharp = listOf(
|
||||
OutlinedRect(),
|
||||
OutlinedOval(),
|
||||
Rect(),
|
||||
Oval,
|
||||
Lasso,
|
||||
FloodFill(),
|
||||
Spray()
|
||||
)
|
||||
+39
@@ -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.draw.domain
|
||||
|
||||
interface ImageDrawApplier<Image, Path, Color> {
|
||||
|
||||
suspend fun applyDrawToImage(
|
||||
drawBehavior: DrawBehavior,
|
||||
pathPaints: List<PathPaint<Path, Color>>,
|
||||
imageUri: String
|
||||
): Image?
|
||||
|
||||
suspend fun applyEraseToImage(
|
||||
pathPaints: List<PathPaint<Path, Color>>,
|
||||
imageUri: String
|
||||
): Image?
|
||||
|
||||
suspend fun applyEraseToImage(
|
||||
pathPaints: List<PathPaint<Path, Color>>,
|
||||
image: Image?,
|
||||
shaderSourceUri: String
|
||||
): Image?
|
||||
|
||||
}
|
||||
@@ -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.draw.domain
|
||||
|
||||
import com.t8rin.imagetoolbox.core.domain.model.IntegerSize
|
||||
import com.t8rin.imagetoolbox.core.domain.model.Pt
|
||||
|
||||
interface PathPaint<Path, Color> {
|
||||
operator fun component1() = path
|
||||
operator fun component2() = strokeWidth
|
||||
operator fun component3() = brushSoftness
|
||||
operator fun component4() = drawColor
|
||||
operator fun component5() = isErasing
|
||||
operator fun component6() = drawMode
|
||||
operator fun component7() = canvasSize
|
||||
operator fun component8() = drawPathMode
|
||||
operator fun component9() = drawLineStyle
|
||||
|
||||
|
||||
val path: Path
|
||||
val strokeWidth: Pt
|
||||
val brushSoftness: Pt
|
||||
val drawColor: Color
|
||||
val isErasing: Boolean
|
||||
val drawMode: DrawMode
|
||||
val canvasSize: IntegerSize
|
||||
val drawPathMode: DrawPathMode
|
||||
val drawLineStyle: DrawLineStyle
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
/*
|
||||
* 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.draw.domain
|
||||
|
||||
import com.t8rin.imagetoolbox.core.domain.model.IntegerSize
|
||||
|
||||
enum class WarpMode {
|
||||
MOVE,
|
||||
GROW,
|
||||
SHRINK,
|
||||
SWIRL_CW,
|
||||
SWIRL_CCW,
|
||||
MIXING
|
||||
}
|
||||
|
||||
data class WarpStroke(
|
||||
val fromX: Float,
|
||||
val fromY: Float,
|
||||
val toX: Float,
|
||||
val toY: Float
|
||||
) {
|
||||
fun scaleToFitCanvas(
|
||||
currentSize: IntegerSize,
|
||||
oldSize: IntegerSize
|
||||
): WarpStroke {
|
||||
val sx = currentSize.width.toFloat() / oldSize.width
|
||||
val sy = currentSize.height.toFloat() / oldSize.height
|
||||
return copy(
|
||||
fromX = fromX * sx,
|
||||
fromY = fromY * sy,
|
||||
toX = toX * sx,
|
||||
toY = toY * sy
|
||||
)
|
||||
}
|
||||
}
|
||||
+383
@@ -0,0 +1,383 @@
|
||||
/*
|
||||
* 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.draw.presentation
|
||||
|
||||
|
||||
import android.net.Uri
|
||||
import androidx.compose.animation.AnimatedContent
|
||||
import androidx.compose.animation.fadeIn
|
||||
import androidx.compose.animation.fadeOut
|
||||
import androidx.compose.animation.togetherWith
|
||||
import androidx.compose.foundation.layout.WindowInsets
|
||||
import androidx.compose.foundation.layout.asPaddingValues
|
||||
import androidx.compose.foundation.layout.aspectRatio
|
||||
import androidx.compose.foundation.layout.calculateStartPadding
|
||||
import androidx.compose.foundation.layout.displayCutout
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.SheetValue
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableFloatStateOf
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.saveable.rememberSaveable
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.ImageBitmap
|
||||
import androidx.compose.ui.platform.LocalLayoutDirection
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.t8rin.dynamic.theme.LocalDynamicThemeState
|
||||
import com.t8rin.imagetoolbox.core.domain.model.coerceIn
|
||||
import com.t8rin.imagetoolbox.core.domain.model.pt
|
||||
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.Tune
|
||||
import com.t8rin.imagetoolbox.core.settings.presentation.provider.LocalSettingsState
|
||||
import com.t8rin.imagetoolbox.core.settings.presentation.provider.rememberAppColorTuple
|
||||
import com.t8rin.imagetoolbox.core.ui.utils.content_pickers.Picker
|
||||
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.utils.helper.isPortraitOrientationAsState
|
||||
import com.t8rin.imagetoolbox.core.ui.utils.provider.LocalScreenSize
|
||||
import com.t8rin.imagetoolbox.core.ui.widget.AdaptiveBottomScaffoldLayoutScreen
|
||||
import com.t8rin.imagetoolbox.core.ui.widget.buttons.BottomButtonsBlock
|
||||
import com.t8rin.imagetoolbox.core.ui.widget.buttons.ShareButton
|
||||
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.dialogs.OneTimeImagePickingDialog
|
||||
import com.t8rin.imagetoolbox.core.ui.widget.dialogs.OneTimeSaveLocationSelectionDialog
|
||||
import com.t8rin.imagetoolbox.core.ui.widget.enhanced.EnhancedIconButton
|
||||
import com.t8rin.imagetoolbox.core.ui.widget.other.DrawLockScreenOrientation
|
||||
import com.t8rin.imagetoolbox.core.ui.widget.other.TopAppBarEmoji
|
||||
import com.t8rin.imagetoolbox.core.ui.widget.saver.ColorSaver
|
||||
import com.t8rin.imagetoolbox.core.ui.widget.saver.PtSaver
|
||||
import com.t8rin.imagetoolbox.core.ui.widget.sheets.ProcessImagesPreferenceSheet
|
||||
import com.t8rin.imagetoolbox.core.ui.widget.text.TopAppBarTitle
|
||||
import com.t8rin.imagetoolbox.core.ui.widget.utils.AutoContentBasedColors
|
||||
import com.t8rin.imagetoolbox.feature.draw.domain.DrawBehavior
|
||||
import com.t8rin.imagetoolbox.feature.draw.domain.DrawMode
|
||||
import com.t8rin.imagetoolbox.feature.draw.presentation.components.BitmapDrawer
|
||||
import com.t8rin.imagetoolbox.feature.draw.presentation.components.controls.DrawContentControls
|
||||
import com.t8rin.imagetoolbox.feature.draw.presentation.components.controls.DrawContentNoDataControls
|
||||
import com.t8rin.imagetoolbox.feature.draw.presentation.components.controls.DrawContentSecondaryControls
|
||||
import com.t8rin.imagetoolbox.feature.draw.presentation.screenLogic.DrawComponent
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
@Composable
|
||||
fun DrawContent(
|
||||
component: DrawComponent,
|
||||
) {
|
||||
val settingsState = LocalSettingsState.current
|
||||
val themeState = LocalDynamicThemeState.current
|
||||
|
||||
val appColorTuple = rememberAppColorTuple()
|
||||
|
||||
val scope = rememberCoroutineScope()
|
||||
|
||||
var showExitDialog by rememberSaveable { mutableStateOf(false) }
|
||||
|
||||
val onBack = {
|
||||
when (component.drawBehavior) {
|
||||
!is DrawBehavior.None if component.haveChanges -> showExitDialog = true
|
||||
|
||||
!is DrawBehavior.None -> {
|
||||
component.resetDrawBehavior()
|
||||
themeState.updateColorTuple(appColorTuple)
|
||||
}
|
||||
|
||||
else -> component.onGoBack()
|
||||
}
|
||||
}
|
||||
|
||||
AutoContentBasedColors(component.imageBitmap)
|
||||
|
||||
val imagePicker = rememberImagePicker { uri: Uri ->
|
||||
component.setUri(uri)
|
||||
}
|
||||
|
||||
val pickImage = imagePicker::pickImage
|
||||
|
||||
val saveBitmap: (oneTimeSaveLocationUri: String?) -> Unit = {
|
||||
component.saveBitmap(
|
||||
oneTimeSaveLocationUri = it
|
||||
)
|
||||
}
|
||||
|
||||
val screenSize = LocalScreenSize.current
|
||||
val isPortrait by isPortraitOrientationAsState()
|
||||
|
||||
var panEnabled by rememberSaveable(component.drawBehavior) { mutableStateOf(false) }
|
||||
|
||||
var strokeWidth by rememberSaveable(
|
||||
component.drawBehavior,
|
||||
stateSaver = PtSaver
|
||||
) { mutableStateOf(settingsState.defaultDrawLineWidth.pt) }
|
||||
|
||||
var drawColor by rememberSaveable(
|
||||
component.drawBehavior,
|
||||
stateSaver = ColorSaver
|
||||
) { mutableStateOf(settingsState.defaultDrawColor) }
|
||||
|
||||
var isEraserOn by rememberSaveable(component.drawBehavior) { mutableStateOf(false) }
|
||||
|
||||
var showLineAngle by rememberSaveable(component.drawBehavior) { mutableStateOf(false) }
|
||||
|
||||
val drawMode = component.drawMode
|
||||
|
||||
var alpha by rememberSaveable(component.drawBehavior, drawMode) {
|
||||
mutableFloatStateOf(if (drawMode is DrawMode.Highlighter) 0.4f else 1f)
|
||||
}
|
||||
|
||||
var brushSoftness by rememberSaveable(component.drawBehavior, drawMode, stateSaver = PtSaver) {
|
||||
mutableStateOf(if (drawMode is DrawMode.Neon) 35.pt else 0.pt)
|
||||
}
|
||||
|
||||
val drawPathMode = component.drawPathMode
|
||||
|
||||
val drawLineStyle = component.drawLineStyle
|
||||
|
||||
LaunchedEffect(drawMode, strokeWidth) {
|
||||
strokeWidth = if (drawMode is DrawMode.Image) {
|
||||
strokeWidth.coerceIn(10.pt, 120.pt)
|
||||
} else {
|
||||
strokeWidth.coerceIn(1.pt, 100.pt)
|
||||
}
|
||||
}
|
||||
|
||||
val secondaryControls = @Composable {
|
||||
DrawContentSecondaryControls(
|
||||
component = component,
|
||||
panEnabled = panEnabled,
|
||||
onTogglePanEnabled = { panEnabled = !panEnabled },
|
||||
isEraserOn = isEraserOn,
|
||||
onToggleIsEraserOn = { isEraserOn = !isEraserOn }
|
||||
)
|
||||
}
|
||||
|
||||
val imageBitmap =
|
||||
component.imageBitmap ?: (component.drawBehavior as? DrawBehavior.Background)?.run {
|
||||
remember(width, height) { ImageBitmap(width, height) }
|
||||
} ?: remember {
|
||||
ImageBitmap(
|
||||
screenSize.widthPx,
|
||||
screenSize.heightPx
|
||||
)
|
||||
}
|
||||
|
||||
var showOneTimeImagePickingDialog by rememberSaveable {
|
||||
mutableStateOf(false)
|
||||
}
|
||||
AdaptiveBottomScaffoldLayoutScreen(
|
||||
title = {
|
||||
TopAppBarTitle(
|
||||
title = stringResource(R.string.draw),
|
||||
input = component.drawBehavior.takeIf { it !is DrawBehavior.None },
|
||||
isLoading = component.isImageLoading,
|
||||
size = null,
|
||||
originalSize = null
|
||||
)
|
||||
},
|
||||
onGoBack = onBack,
|
||||
shouldDisableBackHandler = component.drawBehavior is DrawBehavior.None,
|
||||
actions = {
|
||||
secondaryControls()
|
||||
},
|
||||
topAppBarPersistentActions = { scaffoldState ->
|
||||
if (component.drawBehavior == DrawBehavior.None) TopAppBarEmoji()
|
||||
else {
|
||||
if (isPortrait) {
|
||||
EnhancedIconButton(
|
||||
onClick = {
|
||||
scope.launch {
|
||||
if (scaffoldState.bottomSheetState.currentValue == SheetValue.Expanded) {
|
||||
scaffoldState.bottomSheetState.partialExpand()
|
||||
} else {
|
||||
scaffoldState.bottomSheetState.expand()
|
||||
}
|
||||
}
|
||||
},
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Rounded.Tune,
|
||||
contentDescription = stringResource(R.string.properties)
|
||||
)
|
||||
}
|
||||
}
|
||||
var editSheetData by remember {
|
||||
mutableStateOf(listOf<Uri>())
|
||||
}
|
||||
ShareButton(
|
||||
enabled = component.drawBehavior !is DrawBehavior.None,
|
||||
onShare = component::shareBitmap,
|
||||
onCopy = {
|
||||
component.cacheCurrentImage(Clipboard::copy)
|
||||
},
|
||||
onEdit = {
|
||||
component.cacheCurrentImage { uri ->
|
||||
editSheetData = listOf(uri)
|
||||
}
|
||||
}
|
||||
)
|
||||
ProcessImagesPreferenceSheet(
|
||||
uris = editSheetData,
|
||||
visible = editSheetData.isNotEmpty(),
|
||||
onDismiss = {
|
||||
editSheetData = emptyList()
|
||||
},
|
||||
onNavigate = component.onNavigate
|
||||
)
|
||||
EnhancedIconButton(
|
||||
onClick = component::clearDrawing,
|
||||
enabled = component.drawBehavior !is DrawBehavior.None && component.havePaths
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Outlined.Delete,
|
||||
contentDescription = stringResource(R.string.delete)
|
||||
)
|
||||
}
|
||||
}
|
||||
},
|
||||
mainContent = {
|
||||
AnimatedContent(
|
||||
targetState = imageBitmap,
|
||||
transitionSpec = { fadeIn() togetherWith fadeOut() }
|
||||
) { imageBitmap ->
|
||||
val direction = LocalLayoutDirection.current
|
||||
val aspectRatio = imageBitmap.width / imageBitmap.height.toFloat()
|
||||
BitmapDrawer(
|
||||
imageBitmap = imageBitmap,
|
||||
paths = component.paths,
|
||||
strokeWidth = strokeWidth,
|
||||
brushSoftness = brushSoftness,
|
||||
drawColor = drawColor.copy(alpha),
|
||||
onAddPath = component::addPath,
|
||||
isEraserOn = isEraserOn,
|
||||
drawMode = drawMode,
|
||||
modifier = Modifier
|
||||
.padding(
|
||||
start = WindowInsets
|
||||
.displayCutout
|
||||
.asPaddingValues()
|
||||
.calculateStartPadding(direction)
|
||||
)
|
||||
.padding(16.dp)
|
||||
.aspectRatio(aspectRatio, isPortrait)
|
||||
.fillMaxSize(),
|
||||
panEnabled = panEnabled,
|
||||
onRequestFiltering = component::filter,
|
||||
drawPathMode = drawPathMode,
|
||||
backgroundColor = component.backgroundColor,
|
||||
drawLineStyle = drawLineStyle,
|
||||
helperGridParams = component.helperGridParams,
|
||||
showLineAngle = showLineAngle,
|
||||
spotHealCache = component.spotHealCache,
|
||||
onCacheSpotHealPathResult = component::cacheSpotHealPathResult,
|
||||
onRemovePath = component::removePath
|
||||
)
|
||||
}
|
||||
},
|
||||
controls = {
|
||||
DrawContentControls(
|
||||
component = component,
|
||||
secondaryControls = secondaryControls,
|
||||
drawColor = drawColor,
|
||||
onDrawColorChange = { drawColor = it },
|
||||
strokeWidth = strokeWidth,
|
||||
onStrokeWidthChange = { strokeWidth = it },
|
||||
brushSoftness = brushSoftness,
|
||||
onBrushSoftnessChange = { brushSoftness = it },
|
||||
alpha = alpha,
|
||||
onAlphaChange = { alpha = it },
|
||||
showLineAngle = showLineAngle,
|
||||
onShowLineAngleChange = { showLineAngle = it }
|
||||
)
|
||||
},
|
||||
buttons = {
|
||||
var showFolderSelectionDialog by rememberSaveable {
|
||||
mutableStateOf(false)
|
||||
}
|
||||
BottomButtonsBlock(
|
||||
isNoData = component.drawBehavior is DrawBehavior.None,
|
||||
onSecondaryButtonClick = pickImage,
|
||||
onSecondaryButtonLongClick = {
|
||||
showOneTimeImagePickingDialog = true
|
||||
},
|
||||
isSecondaryButtonVisible = component.drawBehavior !is DrawBehavior.Background,
|
||||
onPrimaryButtonClick = {
|
||||
saveBitmap(null)
|
||||
},
|
||||
isPrimaryButtonVisible = component.drawBehavior !is DrawBehavior.None,
|
||||
onPrimaryButtonLongClick = {
|
||||
showFolderSelectionDialog = true
|
||||
},
|
||||
actions = {
|
||||
if (isPortrait) it()
|
||||
},
|
||||
showNullDataButtonAsContainer = true,
|
||||
drawBothStrokes = true
|
||||
)
|
||||
OneTimeSaveLocationSelectionDialog(
|
||||
visible = showFolderSelectionDialog,
|
||||
onDismiss = { showFolderSelectionDialog = false },
|
||||
onSaveRequest = saveBitmap,
|
||||
formatForFilenameSelection = component.getFormatForFilenameSelection()
|
||||
)
|
||||
OneTimeImagePickingDialog(
|
||||
onDismiss = { showOneTimeImagePickingDialog = false },
|
||||
picker = Picker.Single,
|
||||
imagePicker = imagePicker,
|
||||
visible = showOneTimeImagePickingDialog
|
||||
)
|
||||
},
|
||||
enableNoDataScroll = false,
|
||||
noDataControls = {
|
||||
DrawContentNoDataControls(
|
||||
component = component,
|
||||
onPickImage = pickImage
|
||||
)
|
||||
},
|
||||
canShowScreenData = component.drawBehavior !is DrawBehavior.None,
|
||||
showActionsInTopAppBar = false,
|
||||
mainContentWeight = 0.65f
|
||||
)
|
||||
|
||||
LoadingDialog(
|
||||
visible = component.isSaving || component.isImageLoading,
|
||||
onCancelLoading = component::cancelSaving,
|
||||
canCancel = component.isSaving
|
||||
)
|
||||
|
||||
ExitWithoutSavingDialog(
|
||||
onExit = {
|
||||
if (component.drawBehavior !is DrawBehavior.None) {
|
||||
component.resetDrawBehavior()
|
||||
themeState.updateColorTuple(appColorTuple)
|
||||
} else component.onGoBack()
|
||||
},
|
||||
onDismiss = { showExitDialog = false },
|
||||
visible = showExitDialog
|
||||
)
|
||||
|
||||
DrawLockScreenOrientation()
|
||||
}
|
||||
+720
@@ -0,0 +1,720 @@
|
||||
/*
|
||||
* 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>.
|
||||
*/
|
||||
|
||||
@file:Suppress("COMPOSE_APPLIER_CALL_MISMATCH")
|
||||
|
||||
package com.t8rin.imagetoolbox.feature.draw.presentation.components
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
import android.graphics.Bitmap
|
||||
import android.graphics.PorterDuff
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.BoxWithConstraints
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.DisposableEffect
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.derivedStateOf
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableIntStateOf
|
||||
import androidx.compose.runtime.mutableLongStateOf
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.produceState
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.runtime.snapshotFlow
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.geometry.Offset
|
||||
import androidx.compose.ui.geometry.isSpecified
|
||||
import androidx.compose.ui.geometry.isUnspecified
|
||||
import androidx.compose.ui.geometry.takeOrElse
|
||||
import androidx.compose.ui.graphics.Canvas
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.ImageBitmap
|
||||
import androidx.compose.ui.graphics.Path
|
||||
import androidx.compose.ui.graphics.PathMeasure
|
||||
import androidx.compose.ui.graphics.asAndroidBitmap
|
||||
import androidx.compose.ui.graphics.asAndroidPath
|
||||
import androidx.compose.ui.graphics.asImageBitmap
|
||||
import androidx.compose.ui.graphics.nativeCanvas
|
||||
import androidx.compose.ui.graphics.toArgb
|
||||
import androidx.core.graphics.createBitmap
|
||||
import com.t8rin.imagetoolbox.core.domain.model.IntegerSize
|
||||
import com.t8rin.imagetoolbox.core.domain.model.Pt
|
||||
import com.t8rin.imagetoolbox.core.domain.model.pt
|
||||
import com.t8rin.imagetoolbox.core.filters.domain.model.Filter
|
||||
import com.t8rin.imagetoolbox.core.settings.presentation.provider.LocalSettingsState
|
||||
import com.t8rin.imagetoolbox.core.ui.utils.helper.ImageUtils.createScaledBitmap
|
||||
import com.t8rin.imagetoolbox.core.ui.widget.modifier.HelperGridParams
|
||||
import com.t8rin.imagetoolbox.feature.draw.domain.DrawLineStyle
|
||||
import com.t8rin.imagetoolbox.feature.draw.domain.DrawMode
|
||||
import com.t8rin.imagetoolbox.feature.draw.domain.DrawPathMode
|
||||
import com.t8rin.imagetoolbox.feature.draw.domain.WarpStroke
|
||||
import com.t8rin.imagetoolbox.feature.draw.presentation.components.element.LineAngleIndicator
|
||||
import com.t8rin.imagetoolbox.feature.draw.presentation.components.utils.BitmapDrawerPreview
|
||||
import com.t8rin.imagetoolbox.feature.draw.presentation.components.utils.DrawPathEffectPreview
|
||||
import com.t8rin.imagetoolbox.feature.draw.presentation.components.utils.MotionEvent
|
||||
import com.t8rin.imagetoolbox.feature.draw.presentation.components.utils.copy
|
||||
import com.t8rin.imagetoolbox.feature.draw.presentation.components.utils.drawRepeatedImageOnPath
|
||||
import com.t8rin.imagetoolbox.feature.draw.presentation.components.utils.drawRepeatedTextOnPath
|
||||
import com.t8rin.imagetoolbox.feature.draw.presentation.components.utils.floodFill
|
||||
import com.t8rin.imagetoolbox.feature.draw.presentation.components.utils.handle
|
||||
import com.t8rin.imagetoolbox.feature.draw.presentation.components.utils.overlay
|
||||
import com.t8rin.imagetoolbox.feature.draw.presentation.components.utils.pointerDrawObserver
|
||||
import com.t8rin.imagetoolbox.feature.draw.presentation.components.utils.rememberPaint
|
||||
import com.t8rin.imagetoolbox.feature.draw.presentation.components.utils.rememberPathHelper
|
||||
import com.t8rin.trickle.WarpBrush
|
||||
import com.t8rin.trickle.WarpEngine
|
||||
import com.t8rin.trickle.WarpMode
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.flow.filterNotNull
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import net.engawapg.lib.zoomable.ZoomState
|
||||
import net.engawapg.lib.zoomable.rememberZoomState
|
||||
import kotlin.math.roundToInt
|
||||
import android.graphics.Canvas as AndroidCanvas
|
||||
import android.graphics.Paint as AndroidPaint
|
||||
|
||||
|
||||
@SuppressLint("CoroutineCreationDuringComposition")
|
||||
@Composable
|
||||
fun BitmapDrawer(
|
||||
imageBitmap: ImageBitmap,
|
||||
onRequestFiltering: suspend (Bitmap, List<Filter<*>>) -> Bitmap?,
|
||||
paths: List<UiPathPaint>,
|
||||
brushSoftness: Pt,
|
||||
zoomState: ZoomState = rememberZoomState(maxScale = 30f),
|
||||
onAddPath: (UiPathPaint) -> Unit,
|
||||
strokeWidth: Pt,
|
||||
isEraserOn: Boolean,
|
||||
drawMode: DrawMode,
|
||||
modifier: Modifier,
|
||||
drawPathMode: DrawPathMode = DrawPathMode.Free,
|
||||
onDrawStart: (() -> Unit)? = null,
|
||||
onDraw: ((Bitmap) -> Unit)? = null,
|
||||
onDrawFinish: (() -> Unit)? = null,
|
||||
backgroundColor: Color,
|
||||
panEnabled: Boolean,
|
||||
drawColor: Color,
|
||||
drawLineStyle: DrawLineStyle = DrawLineStyle.None,
|
||||
helperGridParams: HelperGridParams = remember { HelperGridParams() },
|
||||
showLineAngle: Boolean = false,
|
||||
spotHealCache: Map<Int, Bitmap> = emptyMap(),
|
||||
onCacheSpotHealPathResult: (Int, Bitmap) -> Unit = { _, _ -> },
|
||||
onRemovePath: (UiPathPaint) -> Unit = {},
|
||||
) {
|
||||
val scope = rememberCoroutineScope()
|
||||
|
||||
val settingsState = LocalSettingsState.current
|
||||
val magnifierEnabled by remember(zoomState.scale, settingsState.magnifierEnabled) {
|
||||
derivedStateOf {
|
||||
zoomState.scale <= 3f && !panEnabled && settingsState.magnifierEnabled
|
||||
}
|
||||
}
|
||||
val globalTouchPointersCount = remember { mutableIntStateOf(0) }
|
||||
|
||||
var currentDrawPosition by remember { mutableStateOf(Offset.Unspecified) }
|
||||
|
||||
Box(
|
||||
modifier = Modifier.pointerDrawObserver(
|
||||
magnifierEnabled = magnifierEnabled,
|
||||
currentDrawPosition = currentDrawPosition,
|
||||
zoomState = zoomState,
|
||||
globalTouchPointersCount = globalTouchPointersCount,
|
||||
panEnabled = panEnabled
|
||||
),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
BoxWithConstraints(modifier) {
|
||||
val motionEvent = remember { mutableStateOf(MotionEvent.Idle) }
|
||||
var previousDrawPosition by remember { mutableStateOf(Offset.Unspecified) }
|
||||
var drawDownPosition by remember { mutableStateOf(Offset.Unspecified) }
|
||||
|
||||
val imageWidth = constraints.maxWidth
|
||||
val imageHeight = constraints.maxHeight
|
||||
|
||||
val drawImageBitmap by produceState<ImageBitmap?>(
|
||||
initialValue = null,
|
||||
imageBitmap,
|
||||
imageWidth,
|
||||
imageHeight,
|
||||
backgroundColor
|
||||
) {
|
||||
value = null
|
||||
value = withContext(Dispatchers.Default) {
|
||||
imageBitmap.asAndroidBitmap().createScaledBitmap(
|
||||
width = imageWidth,
|
||||
height = imageHeight
|
||||
).apply {
|
||||
val canvas = AndroidCanvas(this)
|
||||
val paint = android.graphics.Paint().apply {
|
||||
color = backgroundColor.toArgb()
|
||||
}
|
||||
canvas.drawRect(0f, 0f, width.toFloat(), height.toFloat(), paint)
|
||||
}.asImageBitmap()
|
||||
}
|
||||
}
|
||||
|
||||
val drawBitmap: ImageBitmap by remember(imageWidth, imageHeight) {
|
||||
derivedStateOf {
|
||||
createBitmap(imageWidth, imageHeight).asImageBitmap()
|
||||
}
|
||||
}
|
||||
|
||||
var invalidations by remember {
|
||||
mutableIntStateOf(0)
|
||||
}
|
||||
|
||||
val needsDrawPathBitmap = !isEraserOn && (
|
||||
drawMode is DrawMode.PathEffect
|
||||
|| drawMode is DrawMode.SpotHeal
|
||||
|| drawMode is DrawMode.Warp
|
||||
)
|
||||
|
||||
val drawPathBitmap: ImageBitmap? by remember(
|
||||
imageWidth,
|
||||
imageHeight,
|
||||
invalidations,
|
||||
needsDrawPathBitmap
|
||||
) {
|
||||
derivedStateOf {
|
||||
if (needsDrawPathBitmap) {
|
||||
createBitmap(imageWidth, imageHeight).asImageBitmap()
|
||||
} else null
|
||||
}
|
||||
}
|
||||
|
||||
LaunchedEffect(
|
||||
paths,
|
||||
drawMode,
|
||||
backgroundColor,
|
||||
drawPathMode,
|
||||
imageWidth,
|
||||
imageHeight
|
||||
) {
|
||||
invalidations++
|
||||
}
|
||||
|
||||
val outputImage by remember(invalidations) {
|
||||
derivedStateOf {
|
||||
drawImageBitmap?.overlay(drawBitmap) ?: imageBitmap
|
||||
}
|
||||
}
|
||||
|
||||
val canvas: Canvas = remember(drawBitmap, imageHeight, imageWidth) {
|
||||
Canvas(drawBitmap)
|
||||
}
|
||||
|
||||
val drawPathCanvas = remember(drawPathBitmap, imageWidth, imageHeight) {
|
||||
drawPathBitmap?.let(::Canvas)
|
||||
}
|
||||
|
||||
val canvasSize by remember(canvas.nativeCanvas) {
|
||||
derivedStateOf {
|
||||
IntegerSize(
|
||||
width = canvas.nativeCanvas.width,
|
||||
height = canvas.nativeCanvas.height
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
val drawPaint by rememberPaint(
|
||||
strokeWidth = strokeWidth,
|
||||
isEraserOn = isEraserOn,
|
||||
drawColor = drawColor,
|
||||
brushSoftness = brushSoftness,
|
||||
drawMode = drawMode,
|
||||
canvasSize = canvasSize,
|
||||
drawPathMode = drawPathMode,
|
||||
drawLineStyle = drawLineStyle
|
||||
)
|
||||
|
||||
var drawPath by remember(
|
||||
drawMode,
|
||||
strokeWidth,
|
||||
isEraserOn,
|
||||
drawColor,
|
||||
brushSoftness,
|
||||
drawPathMode
|
||||
) { mutableStateOf(Path()) }
|
||||
|
||||
var pathWithoutTransformations by remember(
|
||||
drawMode,
|
||||
strokeWidth,
|
||||
isEraserOn,
|
||||
drawColor,
|
||||
brushSoftness,
|
||||
drawPathMode
|
||||
) { mutableStateOf(Path()) }
|
||||
|
||||
var warpRuntimeStrokes by remember(drawMode) {
|
||||
mutableStateOf(emptyList<WarpStroke>())
|
||||
}
|
||||
var warpClearTrigger by remember {
|
||||
mutableIntStateOf(0)
|
||||
}
|
||||
var warpPreviewToken by remember {
|
||||
mutableLongStateOf(0L)
|
||||
}
|
||||
var pendingWarpCommitToken by remember {
|
||||
mutableLongStateOf(-1L)
|
||||
}
|
||||
var previousPathsCount by remember {
|
||||
mutableIntStateOf(paths.size)
|
||||
}
|
||||
|
||||
LaunchedEffect(paths.size) {
|
||||
if (paths.isEmpty() || paths.size < previousPathsCount) {
|
||||
warpClearTrigger++
|
||||
pendingWarpCommitToken = -1L
|
||||
}
|
||||
previousPathsCount = paths.size
|
||||
}
|
||||
|
||||
LaunchedEffect(drawMode, isEraserOn) {
|
||||
if (drawMode !is DrawMode.Warp || isEraserOn) {
|
||||
pendingWarpCommitToken = -1L
|
||||
}
|
||||
}
|
||||
|
||||
val isWarpInputLocked by remember(drawMode, isEraserOn, pendingWarpCommitToken) {
|
||||
derivedStateOf {
|
||||
drawMode is DrawMode.Warp && !isEraserOn && pendingWarpCommitToken >= 0L
|
||||
}
|
||||
}
|
||||
|
||||
with(canvas) {
|
||||
with(nativeCanvas) {
|
||||
drawColor(Color.Transparent.toArgb(), PorterDuff.Mode.CLEAR)
|
||||
drawColor(backgroundColor.toArgb())
|
||||
|
||||
paths.forEach { uiPathPaint ->
|
||||
UiPathPaintCanvasAction(
|
||||
uiPathPaint = uiPathPaint,
|
||||
invalidations = invalidations,
|
||||
onInvalidate = { invalidations++ },
|
||||
pathsCount = paths.size,
|
||||
backgroundColor = backgroundColor,
|
||||
drawImageBitmap = drawImageBitmap ?: imageBitmap,
|
||||
drawBitmap = drawBitmap,
|
||||
onClearDrawPath = {
|
||||
drawPath = Path()
|
||||
warpClearTrigger++
|
||||
warpRuntimeStrokes = emptyList()
|
||||
},
|
||||
onClearWarpDrawPath = { token ->
|
||||
if (token == pendingWarpCommitToken) {
|
||||
drawPath = Path()
|
||||
warpClearTrigger++
|
||||
warpRuntimeStrokes = emptyList()
|
||||
pendingWarpCommitToken = -1L
|
||||
}
|
||||
},
|
||||
onRequestFiltering = onRequestFiltering,
|
||||
spotHealCache = spotHealCache,
|
||||
onCacheSpotHealPathResult = onCacheSpotHealPathResult,
|
||||
onCancel = onRemovePath,
|
||||
canvasSize = canvasSize,
|
||||
scope = scope
|
||||
)
|
||||
}
|
||||
|
||||
if ((drawMode !is DrawMode.PathEffect && drawMode !is DrawMode.Warp) || isEraserOn) {
|
||||
val androidPath by remember(drawPath) {
|
||||
derivedStateOf {
|
||||
drawPath.asAndroidPath()
|
||||
}
|
||||
}
|
||||
if (drawMode is DrawMode.Text && !isEraserOn) {
|
||||
if (drawMode.isRepeated) {
|
||||
drawRepeatedTextOnPath(
|
||||
text = drawMode.text,
|
||||
path = androidPath,
|
||||
paint = drawPaint,
|
||||
interval = drawMode.repeatingInterval.toPx(canvasSize)
|
||||
)
|
||||
} else {
|
||||
drawTextOnPath(drawMode.text, androidPath, 0f, 0f, drawPaint)
|
||||
}
|
||||
} else if (drawMode is DrawMode.Image && !isEraserOn) {
|
||||
drawRepeatedImageOnPath(
|
||||
drawMode = drawMode,
|
||||
strokeWidth = strokeWidth,
|
||||
canvasSize = canvasSize,
|
||||
path = androidPath,
|
||||
paint = drawPaint,
|
||||
invalidations = invalidations
|
||||
)
|
||||
} else if (drawMode is DrawMode.SpotHeal && !isEraserOn) {
|
||||
drawPathCanvas?.nativeCanvas?.let {
|
||||
with(it) {
|
||||
drawColor(Color.Transparent.toArgb(), PorterDuff.Mode.CLEAR)
|
||||
drawPath(
|
||||
androidPath,
|
||||
drawPaint.apply { color = Color.Red.copy(0.5f).toArgb() }
|
||||
)
|
||||
}
|
||||
}
|
||||
} else if (drawPathMode is DrawPathMode.Outlined) {
|
||||
drawPathMode.fillColor?.let { fillColor ->
|
||||
val filledPaint = remember(fillColor, drawPaint) {
|
||||
AndroidPaint().apply {
|
||||
set(drawPaint)
|
||||
style = AndroidPaint.Style.FILL
|
||||
color = fillColor.colorInt
|
||||
if (Color(fillColor.colorInt).alpha == 1f) {
|
||||
alpha =
|
||||
(drawColor.alpha * 255).roundToInt()
|
||||
.coerceIn(0, 255)
|
||||
}
|
||||
pathEffect = null
|
||||
}
|
||||
}
|
||||
|
||||
drawPath(androidPath, filledPaint)
|
||||
}
|
||||
drawPath(androidPath, drawPaint)
|
||||
} else {
|
||||
drawPath(androidPath, drawPaint)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val drawHelper by rememberPathHelper(
|
||||
drawDownPosition = drawDownPosition,
|
||||
currentDrawPosition = currentDrawPosition,
|
||||
onPathChange = { drawPath = it },
|
||||
strokeWidth = strokeWidth,
|
||||
canvasSize = canvasSize,
|
||||
drawPathMode = drawPathMode,
|
||||
isEraserOn = isEraserOn,
|
||||
drawMode = drawMode
|
||||
)
|
||||
|
||||
motionEvent.value.handle(
|
||||
onDown = {
|
||||
if (drawMode is DrawMode.Warp && !isEraserOn) {
|
||||
warpPreviewToken++
|
||||
warpRuntimeStrokes = emptyList()
|
||||
drawPath = Path()
|
||||
pathWithoutTransformations = Path()
|
||||
} else {
|
||||
warpClearTrigger++
|
||||
warpRuntimeStrokes = emptyList()
|
||||
}
|
||||
|
||||
if (currentDrawPosition.isSpecified) {
|
||||
onDrawStart?.invoke()
|
||||
drawPath.moveTo(currentDrawPosition.x, currentDrawPosition.y)
|
||||
previousDrawPosition = currentDrawPosition
|
||||
pathWithoutTransformations = drawPath.copy()
|
||||
} else {
|
||||
drawPath = Path()
|
||||
pathWithoutTransformations = Path()
|
||||
}
|
||||
|
||||
motionEvent.value = MotionEvent.Idle
|
||||
},
|
||||
onMove = {
|
||||
if (drawMode is DrawMode.Warp && !isEraserOn) {
|
||||
if (
|
||||
previousDrawPosition.isSpecified &&
|
||||
currentDrawPosition.isSpecified
|
||||
) {
|
||||
warpRuntimeStrokes += WarpStroke(
|
||||
fromX = previousDrawPosition.x,
|
||||
fromY = previousDrawPosition.y,
|
||||
toX = currentDrawPosition.x,
|
||||
toY = currentDrawPosition.y
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
drawHelper.drawPath(
|
||||
currentDrawPath = drawPath,
|
||||
onDrawFreeArrow = {
|
||||
if (previousDrawPosition.isUnspecified && currentDrawPosition.isSpecified) {
|
||||
drawPath = Path().apply {
|
||||
moveTo(
|
||||
currentDrawPosition.x,
|
||||
currentDrawPosition.y
|
||||
)
|
||||
}
|
||||
pathWithoutTransformations = drawPath.copy()
|
||||
previousDrawPosition = currentDrawPosition
|
||||
}
|
||||
if (previousDrawPosition.isSpecified && currentDrawPosition.isSpecified) {
|
||||
drawPath = pathWithoutTransformations
|
||||
drawPath.quadraticTo(
|
||||
previousDrawPosition.x,
|
||||
previousDrawPosition.y,
|
||||
(previousDrawPosition.x + currentDrawPosition.x) / 2,
|
||||
(previousDrawPosition.y + currentDrawPosition.y) / 2
|
||||
)
|
||||
previousDrawPosition = currentDrawPosition
|
||||
|
||||
pathWithoutTransformations = drawPath.copy()
|
||||
|
||||
drawArrowsIfNeeded(drawPath)
|
||||
}
|
||||
},
|
||||
onBaseDraw = {
|
||||
if (previousDrawPosition.isUnspecified && currentDrawPosition.isSpecified) {
|
||||
drawPath.moveTo(currentDrawPosition.x, currentDrawPosition.y)
|
||||
previousDrawPosition = currentDrawPosition
|
||||
}
|
||||
|
||||
if (currentDrawPosition.isSpecified && previousDrawPosition.isSpecified) {
|
||||
drawPath.quadraticTo(
|
||||
previousDrawPosition.x,
|
||||
previousDrawPosition.y,
|
||||
(previousDrawPosition.x + currentDrawPosition.x) / 2,
|
||||
(previousDrawPosition.y + currentDrawPosition.y) / 2
|
||||
)
|
||||
}
|
||||
previousDrawPosition = currentDrawPosition
|
||||
},
|
||||
)
|
||||
|
||||
motionEvent.value = MotionEvent.Idle
|
||||
},
|
||||
onUp = {
|
||||
if (currentDrawPosition.isSpecified && drawDownPosition.isSpecified) {
|
||||
if (drawMode is DrawMode.Warp && warpRuntimeStrokes.isNotEmpty() && !isEraserOn) {
|
||||
PathMeasure().apply {
|
||||
setPath(drawPath, false)
|
||||
}.let {
|
||||
it.getPosition(it.length)
|
||||
}.takeOrElse { currentDrawPosition }.let { lastPoint ->
|
||||
warpRuntimeStrokes += WarpStroke(
|
||||
fromX = lastPoint.x,
|
||||
fromY = lastPoint.y,
|
||||
toX = currentDrawPosition.x,
|
||||
toY = currentDrawPosition.y
|
||||
)
|
||||
}
|
||||
|
||||
onAddPath(
|
||||
UiPathPaint(
|
||||
path = drawPath,
|
||||
strokeWidth = strokeWidth,
|
||||
brushSoftness = 0.pt,
|
||||
drawColor = Color.Transparent,
|
||||
isErasing = false,
|
||||
drawMode = drawMode.copy(
|
||||
strokes = warpRuntimeStrokes.toList(),
|
||||
previewClearToken = warpPreviewToken
|
||||
),
|
||||
canvasSize = canvasSize,
|
||||
drawPathMode = DrawPathMode.Free,
|
||||
drawLineStyle = DrawLineStyle.None
|
||||
)
|
||||
)
|
||||
pendingWarpCommitToken = warpPreviewToken
|
||||
} else {
|
||||
drawHelper.drawPath(
|
||||
currentDrawPath = null,
|
||||
onDrawFreeArrow = {
|
||||
drawPath = pathWithoutTransformations
|
||||
PathMeasure().apply {
|
||||
setPath(drawPath, false)
|
||||
}.let {
|
||||
it.getPosition(it.length)
|
||||
}.let { lastPoint ->
|
||||
if (!lastPoint.isSpecified) {
|
||||
drawPath.moveTo(
|
||||
currentDrawPosition.x,
|
||||
currentDrawPosition.y
|
||||
)
|
||||
}
|
||||
drawPath.lineTo(
|
||||
currentDrawPosition.x,
|
||||
currentDrawPosition.y
|
||||
)
|
||||
}
|
||||
|
||||
drawArrowsIfNeeded(drawPath)
|
||||
},
|
||||
onBaseDraw = {
|
||||
PathMeasure().apply {
|
||||
setPath(drawPath, false)
|
||||
}.let {
|
||||
it.getPosition(it.length)
|
||||
}.takeOrElse { currentDrawPosition }.let { lastPoint ->
|
||||
drawPath.moveTo(lastPoint.x, lastPoint.y)
|
||||
drawPath.lineTo(
|
||||
currentDrawPosition.x,
|
||||
currentDrawPosition.y
|
||||
)
|
||||
}
|
||||
},
|
||||
onFloodFill = { tolerance ->
|
||||
outputImage
|
||||
.floodFill(
|
||||
offset = currentDrawPosition,
|
||||
tolerance = tolerance
|
||||
)
|
||||
?.let { drawPath = it }
|
||||
}
|
||||
)
|
||||
|
||||
onAddPath(
|
||||
UiPathPaint(
|
||||
path = drawPath,
|
||||
strokeWidth = strokeWidth,
|
||||
brushSoftness = brushSoftness,
|
||||
drawColor = drawColor,
|
||||
isErasing = isEraserOn,
|
||||
drawMode = drawMode,
|
||||
canvasSize = canvasSize,
|
||||
drawPathMode = drawPathMode,
|
||||
drawLineStyle = drawLineStyle
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
currentDrawPosition = Offset.Unspecified
|
||||
previousDrawPosition = Offset.Unspecified
|
||||
motionEvent.value = MotionEvent.Idle
|
||||
|
||||
scope.launch {
|
||||
if ((drawMode is DrawMode.PathEffect || drawMode is DrawMode.SpotHeal || drawMode is DrawMode.Warp) && !isEraserOn) Unit
|
||||
else drawPath = Path()
|
||||
|
||||
pathWithoutTransformations = Path()
|
||||
}
|
||||
onDrawFinish?.invoke()
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
if (drawMode is DrawMode.PathEffect && !isEraserOn && drawPathCanvas != null) {
|
||||
DrawPathEffectPreview(
|
||||
drawPathCanvas = drawPathCanvas,
|
||||
drawMode = drawMode,
|
||||
canvasSize = canvasSize,
|
||||
imageWidth = imageWidth,
|
||||
imageHeight = imageHeight,
|
||||
outputImage = outputImage,
|
||||
onRequestFiltering = onRequestFiltering,
|
||||
paths = paths,
|
||||
drawPath = drawPath,
|
||||
backgroundColor = backgroundColor,
|
||||
strokeWidth = strokeWidth,
|
||||
drawPathMode = drawPathMode
|
||||
)
|
||||
}
|
||||
|
||||
var warpEngine by remember {
|
||||
mutableStateOf<WarpEngine?>(null)
|
||||
}
|
||||
|
||||
LaunchedEffect(warpClearTrigger, drawMode) {
|
||||
if (drawMode is DrawMode.Warp && !isEraserOn) {
|
||||
warpEngine?.release()
|
||||
warpEngine = WarpEngine(
|
||||
src = outputImage.asAndroidBitmap()
|
||||
)
|
||||
} else {
|
||||
warpEngine?.release()
|
||||
warpEngine = null
|
||||
}
|
||||
}
|
||||
|
||||
DisposableEffect(Unit) {
|
||||
onDispose {
|
||||
warpEngine?.release()
|
||||
warpEngine = null
|
||||
}
|
||||
}
|
||||
|
||||
LaunchedEffect(warpEngine) {
|
||||
snapshotFlow { warpRuntimeStrokes.lastOrNull() }
|
||||
.filterNotNull()
|
||||
.collect {
|
||||
val engine = warpEngine ?: return@collect
|
||||
val warpMode = drawMode as? DrawMode.Warp ?: return@collect
|
||||
|
||||
engine.applyStroke(
|
||||
fromX = it.fromX,
|
||||
fromY = it.fromY,
|
||||
toX = it.toX,
|
||||
toY = it.toY,
|
||||
brush = WarpBrush(
|
||||
radius = strokeWidth.toPx(canvasSize),
|
||||
strength = warpMode.strength,
|
||||
hardness = warpMode.hardness
|
||||
),
|
||||
mode = WarpMode.valueOf(warpMode.warpMode.name)
|
||||
)
|
||||
invalidations++
|
||||
}
|
||||
}
|
||||
|
||||
val warpedImage by remember(invalidations, warpEngine) {
|
||||
derivedStateOf {
|
||||
warpEngine?.takeIf { warpRuntimeStrokes.isNotEmpty() }?.let { engine ->
|
||||
engine.render().asImageBitmap().also {
|
||||
it.prepareToDraw()
|
||||
}
|
||||
} ?: drawPathBitmap?.let(outputImage::overlay) ?: outputImage
|
||||
}
|
||||
}
|
||||
|
||||
val previewBitmap by remember(invalidations) {
|
||||
derivedStateOf {
|
||||
if (drawMode is DrawMode.Warp) {
|
||||
warpedImage
|
||||
} else {
|
||||
drawPathBitmap?.let(outputImage::overlay) ?: outputImage
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
onDraw?.let {
|
||||
LaunchedEffect(invalidations) {
|
||||
onDraw(previewBitmap.asAndroidBitmap())
|
||||
}
|
||||
}
|
||||
|
||||
BitmapDrawerPreview(
|
||||
preview = previewBitmap,
|
||||
globalTouchPointersCount = globalTouchPointersCount,
|
||||
onReceiveMotionEvent = { motionEvent.value = it },
|
||||
onInvalidate = { invalidations++ },
|
||||
onUpdateCurrentDrawPosition = { currentDrawPosition = it },
|
||||
onUpdateDrawDownPosition = { drawDownPosition = it },
|
||||
drawEnabled = !panEnabled && !isWarpInputLocked && drawImageBitmap != null,
|
||||
helperGridParams = helperGridParams,
|
||||
drawBitmapBorder = settingsState.drawBitmapBorder
|
||||
)
|
||||
|
||||
if (showLineAngle && drawPathMode.canShowLineAngle() && drawDownPosition.isSpecified && currentDrawPosition.isSpecified) {
|
||||
LineAngleIndicator(
|
||||
drawDownPosition = drawDownPosition,
|
||||
currentDrawPosition = currentDrawPosition,
|
||||
imageWidth = imageWidth,
|
||||
imageHeight = imageHeight,
|
||||
isMagnifierEnabled = magnifierEnabled
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+60
@@ -0,0 +1,60 @@
|
||||
/*
|
||||
* 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.draw.presentation.components
|
||||
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import com.t8rin.imagetoolbox.core.resources.Icons
|
||||
import androidx.compose.runtime.Composable
|
||||
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.colors.util.roundToTwoDigits
|
||||
import com.t8rin.imagetoolbox.core.resources.R
|
||||
import com.t8rin.imagetoolbox.core.resources.icons.Dots
|
||||
import com.t8rin.imagetoolbox.core.ui.widget.enhanced.EnhancedSliderItem
|
||||
import com.t8rin.imagetoolbox.core.ui.widget.modifier.ShapeDefaults
|
||||
|
||||
@Composable
|
||||
fun BrushSoftnessSelector(
|
||||
modifier: Modifier,
|
||||
value: Float,
|
||||
color: Color = Color.Unspecified,
|
||||
onValueChange: (Float) -> Unit
|
||||
) {
|
||||
EnhancedSliderItem(
|
||||
modifier = modifier,
|
||||
value = value,
|
||||
title = stringResource(R.string.brush_softness),
|
||||
valueRange = 0f..100f,
|
||||
onValueChange = {
|
||||
onValueChange(it.roundToTwoDigits())
|
||||
},
|
||||
valueSuffix = " Pt",
|
||||
sliderModifier = Modifier
|
||||
.padding(
|
||||
top = 14.dp,
|
||||
start = 12.dp,
|
||||
end = 12.dp,
|
||||
bottom = 10.dp
|
||||
),
|
||||
icon = Icons.Rounded.Dots,
|
||||
shape = ShapeDefaults.extraLarge,
|
||||
containerColor = color
|
||||
)
|
||||
}
|
||||
+57
@@ -0,0 +1,57 @@
|
||||
/*
|
||||
* 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.draw.presentation.components
|
||||
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import com.t8rin.imagetoolbox.core.resources.Icons
|
||||
import androidx.compose.runtime.Composable
|
||||
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.R
|
||||
import com.t8rin.imagetoolbox.core.resources.icons.BrushColor
|
||||
import com.t8rin.imagetoolbox.core.ui.widget.color_picker.ColorSelectionRowDefaults
|
||||
import com.t8rin.imagetoolbox.core.ui.widget.controls.selection.ColorRowSelector
|
||||
import com.t8rin.imagetoolbox.core.ui.widget.modifier.ShapeDefaults
|
||||
import com.t8rin.imagetoolbox.core.ui.widget.modifier.container
|
||||
|
||||
@Composable
|
||||
fun DrawColorSelector(
|
||||
modifier: Modifier = Modifier
|
||||
.padding(start = 16.dp, end = 16.dp, bottom = 16.dp),
|
||||
value: Color,
|
||||
onValueChange: (Color) -> Unit,
|
||||
color: Color = Color.Unspecified,
|
||||
titleText: String = stringResource(R.string.paint_color),
|
||||
defaultColors: List<Color> = ColorSelectionRowDefaults.colorList,
|
||||
) {
|
||||
ColorRowSelector(
|
||||
value = value,
|
||||
onValueChange = onValueChange,
|
||||
modifier = modifier
|
||||
.container(
|
||||
shape = ShapeDefaults.extraLarge,
|
||||
color = color
|
||||
),
|
||||
title = titleText,
|
||||
allowAlpha = false,
|
||||
icon = Icons.Outlined.BrushColor,
|
||||
defaultColors = defaultColors
|
||||
)
|
||||
}
|
||||
+54
@@ -0,0 +1,54 @@
|
||||
/*
|
||||
* 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.draw.presentation.components
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import com.t8rin.imagetoolbox.core.resources.Icons
|
||||
import com.t8rin.imagetoolbox.core.resources.R
|
||||
import com.t8rin.imagetoolbox.core.resources.icons.Line
|
||||
import com.t8rin.imagetoolbox.core.ui.widget.modifier.ShapeDefaults
|
||||
import com.t8rin.imagetoolbox.core.ui.widget.preferences.PreferenceRowSwitch
|
||||
import com.t8rin.imagetoolbox.feature.draw.domain.DrawPathMode
|
||||
|
||||
@Composable
|
||||
fun DrawLineAngleSelector(
|
||||
checked: Boolean,
|
||||
onCheckedChange: (Boolean) -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
shape: androidx.compose.ui.graphics.Shape = ShapeDefaults.extraLarge,
|
||||
startIcon: ImageVector? = Icons.Rounded.Line
|
||||
) {
|
||||
PreferenceRowSwitch(
|
||||
modifier = modifier,
|
||||
shape = shape,
|
||||
title = stringResource(R.string.show_line_angle),
|
||||
subtitle = stringResource(R.string.show_line_angle_sub),
|
||||
checked = checked,
|
||||
onClick = onCheckedChange,
|
||||
startIcon = startIcon
|
||||
)
|
||||
}
|
||||
|
||||
fun DrawPathMode.canShowLineAngle(): Boolean {
|
||||
return this is DrawPathMode.Line ||
|
||||
this is DrawPathMode.LinePointingArrow ||
|
||||
this is DrawPathMode.DoubleLinePointingArrow
|
||||
}
|
||||
+461
@@ -0,0 +1,461 @@
|
||||
/*
|
||||
* 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.draw.presentation.components
|
||||
|
||||
import androidx.compose.animation.AnimatedVisibility
|
||||
import androidx.compose.animation.expandVertically
|
||||
import androidx.compose.animation.fadeIn
|
||||
import androidx.compose.animation.fadeOut
|
||||
import androidx.compose.animation.shrinkVertically
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.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.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.lazy.grid.GridCells
|
||||
import androidx.compose.foundation.lazy.grid.LazyHorizontalGrid
|
||||
import androidx.compose.foundation.lazy.grid.items
|
||||
import androidx.compose.foundation.lazy.grid.rememberLazyGridState
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.DisposableEffect
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.SideEffect
|
||||
import androidx.compose.runtime.derivedStateOf
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableFloatStateOf
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.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.graphics.Shape
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import com.t8rin.colors.util.roundToTwoDigits
|
||||
import com.t8rin.imagetoolbox.core.domain.model.pt
|
||||
import com.t8rin.imagetoolbox.core.resources.Icons
|
||||
import com.t8rin.imagetoolbox.core.resources.R
|
||||
import com.t8rin.imagetoolbox.core.resources.icons.BoldLine
|
||||
import com.t8rin.imagetoolbox.core.resources.icons.DashedLine
|
||||
import com.t8rin.imagetoolbox.core.resources.icons.DotDashedLine
|
||||
import com.t8rin.imagetoolbox.core.resources.icons.StampedLine
|
||||
import com.t8rin.imagetoolbox.core.resources.icons.ZigzagLine
|
||||
import com.t8rin.imagetoolbox.core.resources.shapes.CloverShape
|
||||
import com.t8rin.imagetoolbox.core.resources.shapes.MaterialStarShape
|
||||
import com.t8rin.imagetoolbox.core.resources.utils.animation.animateColorAsState
|
||||
import com.t8rin.imagetoolbox.core.settings.presentation.model.IconShape
|
||||
import com.t8rin.imagetoolbox.core.ui.widget.buttons.SupportingButton
|
||||
import com.t8rin.imagetoolbox.core.ui.widget.enhanced.EnhancedButton
|
||||
import com.t8rin.imagetoolbox.core.ui.widget.enhanced.EnhancedButtonGroup
|
||||
import com.t8rin.imagetoolbox.core.ui.widget.enhanced.EnhancedModalBottomSheet
|
||||
import com.t8rin.imagetoolbox.core.ui.widget.enhanced.EnhancedSliderItem
|
||||
import com.t8rin.imagetoolbox.core.ui.widget.enhanced.enhancedFlingBehavior
|
||||
import com.t8rin.imagetoolbox.core.ui.widget.enhanced.enhancedVerticalScroll
|
||||
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.modifier.fadingEdges
|
||||
import com.t8rin.imagetoolbox.core.ui.widget.text.AutoSizeText
|
||||
import com.t8rin.imagetoolbox.core.ui.widget.text.TitleItem
|
||||
import com.t8rin.imagetoolbox.feature.draw.domain.DrawLineStyle
|
||||
|
||||
@Composable
|
||||
fun DrawLineStyleSelector(
|
||||
modifier: Modifier,
|
||||
value: DrawLineStyle,
|
||||
onValueChange: (DrawLineStyle) -> Unit,
|
||||
values: List<DrawLineStyle> = DrawLineStyle.entries
|
||||
) {
|
||||
var isSheetVisible by rememberSaveable { mutableStateOf(false) }
|
||||
|
||||
LaunchedEffect(values, value) {
|
||||
if (values.filterIsInstance(value::class.java).isEmpty() && values.isNotEmpty()) {
|
||||
onValueChange(values.first())
|
||||
}
|
||||
}
|
||||
|
||||
Column(
|
||||
modifier = modifier
|
||||
.container(ShapeDefaults.extraLarge),
|
||||
horizontalAlignment = Alignment.CenterHorizontally
|
||||
) {
|
||||
EnhancedButtonGroup(
|
||||
enabled = true,
|
||||
itemCount = values.size,
|
||||
title = {
|
||||
Text(
|
||||
text = stringResource(R.string.line_style),
|
||||
textAlign = TextAlign.Center,
|
||||
fontWeight = FontWeight.Medium
|
||||
)
|
||||
Spacer(modifier = Modifier.width(8.dp))
|
||||
SupportingButton(
|
||||
onClick = {
|
||||
isSheetVisible = true
|
||||
}
|
||||
)
|
||||
},
|
||||
selectedIndex = values.indexOfFirst {
|
||||
value::class.isInstance(it)
|
||||
},
|
||||
itemContent = {
|
||||
Icon(
|
||||
imageVector = values[it].getIcon(),
|
||||
contentDescription = null
|
||||
)
|
||||
},
|
||||
onIndexChange = {
|
||||
onValueChange(values[it])
|
||||
},
|
||||
activeButtonColor = MaterialTheme.colorScheme.secondaryContainer
|
||||
)
|
||||
var lineStyle by remember {
|
||||
mutableStateOf<DrawLineStyle?>(value)
|
||||
}
|
||||
|
||||
AnimatedVisibility(
|
||||
visible = value is DrawLineStyle.Dashed,
|
||||
enter = fadeIn() + expandVertically(),
|
||||
exit = fadeOut() + shrinkVertically()
|
||||
) {
|
||||
SideEffect {
|
||||
if (value is DrawLineStyle.Dashed) {
|
||||
lineStyle = value
|
||||
}
|
||||
}
|
||||
DisposableEffect(Unit) {
|
||||
onDispose { lineStyle = null }
|
||||
}
|
||||
val style = lineStyle as? DrawLineStyle.Dashed ?: return@AnimatedVisibility
|
||||
|
||||
Column {
|
||||
EnhancedSliderItem(
|
||||
value = style.size.value,
|
||||
title = stringResource(R.string.dash_size),
|
||||
valueRange = 0f..100f,
|
||||
internalStateTransformation = {
|
||||
it.roundToTwoDigits()
|
||||
},
|
||||
onValueChange = {
|
||||
onValueChange(
|
||||
style.copy(
|
||||
size = it.pt
|
||||
)
|
||||
)
|
||||
},
|
||||
valueSuffix = " Pt",
|
||||
containerColor = MaterialTheme.colorScheme.surface,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 8.dp),
|
||||
shape = ShapeDefaults.top
|
||||
)
|
||||
Spacer(modifier = Modifier.height(4.dp))
|
||||
EnhancedSliderItem(
|
||||
value = style.gap.value,
|
||||
title = stringResource(R.string.gap_size),
|
||||
valueRange = 0f..100f,
|
||||
internalStateTransformation = {
|
||||
it.roundToTwoDigits()
|
||||
},
|
||||
onValueChange = {
|
||||
onValueChange(
|
||||
style.copy(
|
||||
gap = it.pt
|
||||
)
|
||||
)
|
||||
},
|
||||
valueSuffix = " Pt",
|
||||
containerColor = MaterialTheme.colorScheme.surface,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 8.dp),
|
||||
shape = ShapeDefaults.bottom
|
||||
)
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
}
|
||||
}
|
||||
|
||||
AnimatedVisibility(
|
||||
visible = value is DrawLineStyle.ZigZag,
|
||||
enter = fadeIn() + expandVertically(),
|
||||
exit = fadeOut() + shrinkVertically()
|
||||
) {
|
||||
SideEffect {
|
||||
if (value is DrawLineStyle.ZigZag) {
|
||||
lineStyle = value
|
||||
}
|
||||
}
|
||||
DisposableEffect(Unit) {
|
||||
onDispose { lineStyle = null }
|
||||
}
|
||||
val style = lineStyle as? DrawLineStyle.ZigZag ?: return@AnimatedVisibility
|
||||
|
||||
Column {
|
||||
var ratio by remember {
|
||||
mutableFloatStateOf(style.heightRatio)
|
||||
}
|
||||
EnhancedSliderItem(
|
||||
value = ratio,
|
||||
title = stringResource(R.string.zigzag_ratio),
|
||||
valueRange = 0.5f..20f,
|
||||
internalStateTransformation = {
|
||||
it.roundToTwoDigits()
|
||||
},
|
||||
onValueChange = {
|
||||
ratio = it
|
||||
onValueChange(
|
||||
style.copy(
|
||||
heightRatio = 20.5f - it
|
||||
)
|
||||
)
|
||||
},
|
||||
containerColor = MaterialTheme.colorScheme.surface,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 8.dp),
|
||||
shape = ShapeDefaults.default
|
||||
)
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
}
|
||||
}
|
||||
|
||||
AnimatedVisibility(
|
||||
visible = value is DrawLineStyle.Stamped<*>,
|
||||
enter = fadeIn() + expandVertically(),
|
||||
exit = fadeOut() + shrinkVertically()
|
||||
) {
|
||||
SideEffect {
|
||||
if (value is DrawLineStyle.Stamped<*>) {
|
||||
lineStyle = value
|
||||
}
|
||||
}
|
||||
DisposableEffect(Unit) {
|
||||
onDispose { lineStyle = null }
|
||||
}
|
||||
val style = lineStyle as? DrawLineStyle.Stamped<*> ?: return@AnimatedVisibility
|
||||
|
||||
val shape = if (style.shape is Shape) style.shape
|
||||
else MaterialStarShape
|
||||
|
||||
Column {
|
||||
val shapes = IconShape.entriesNoRandom
|
||||
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.padding(horizontal = 8.dp)
|
||||
.container(
|
||||
shape = ShapeDefaults.top,
|
||||
color = MaterialTheme.colorScheme.surface,
|
||||
resultPadding = 0.dp
|
||||
)
|
||||
) {
|
||||
val state = rememberLazyGridState()
|
||||
LazyHorizontalGrid(
|
||||
state = state,
|
||||
rows = GridCells.Adaptive(48.dp),
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.height(240.dp)
|
||||
.fadingEdges(
|
||||
scrollableState = state,
|
||||
spanCount = 4,
|
||||
length = 32.dp
|
||||
),
|
||||
verticalArrangement = Arrangement.spacedBy(
|
||||
space = 6.dp,
|
||||
alignment = Alignment.CenterVertically
|
||||
),
|
||||
horizontalArrangement = Arrangement.spacedBy(
|
||||
space = 6.dp,
|
||||
alignment = Alignment.CenterHorizontally
|
||||
),
|
||||
contentPadding = PaddingValues(8.dp),
|
||||
flingBehavior = enhancedFlingBehavior()
|
||||
) {
|
||||
items(shapes) { iconShape ->
|
||||
val selected by remember(iconShape, shape) {
|
||||
derivedStateOf {
|
||||
iconShape.shape == shape
|
||||
}
|
||||
}
|
||||
val color by animateColorAsState(
|
||||
if (selected) MaterialTheme.colorScheme.primaryContainer
|
||||
else Color.Unspecified
|
||||
)
|
||||
val borderColor by animateColorAsState(
|
||||
if (selected) {
|
||||
MaterialTheme.colorScheme.onPrimaryContainer.copy(0.7f)
|
||||
} else MaterialTheme.colorScheme.onSecondaryContainer.copy(
|
||||
alpha = 0.1f
|
||||
)
|
||||
)
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.aspectRatio(1f)
|
||||
.container(
|
||||
color = color,
|
||||
shape = CloverShape,
|
||||
borderColor = borderColor,
|
||||
resultPadding = 0.dp
|
||||
)
|
||||
.hapticsClickable {
|
||||
onValueChange(
|
||||
DrawLineStyle.Stamped(
|
||||
shape = iconShape.shape,
|
||||
spacing = style.spacing
|
||||
)
|
||||
)
|
||||
},
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(30.dp)
|
||||
.container(
|
||||
borderWidth = 2.dp,
|
||||
borderColor = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
color = Color.Transparent,
|
||||
shape = iconShape.shape
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Spacer(modifier = Modifier.height(4.dp))
|
||||
EnhancedSliderItem(
|
||||
value = style.spacing.value,
|
||||
title = stringResource(R.string.spacing),
|
||||
valueRange = 0f..100f,
|
||||
internalStateTransformation = {
|
||||
it.roundToTwoDigits()
|
||||
},
|
||||
onValueChange = {
|
||||
onValueChange(
|
||||
style.copy(
|
||||
spacing = it.pt
|
||||
)
|
||||
)
|
||||
},
|
||||
valueSuffix = " Pt",
|
||||
containerColor = MaterialTheme.colorScheme.surface,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 8.dp),
|
||||
shape = ShapeDefaults.bottom
|
||||
)
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
}
|
||||
}
|
||||
}
|
||||
EnhancedModalBottomSheet(
|
||||
sheetContent = {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.enhancedVerticalScroll(rememberScrollState())
|
||||
.padding(8.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(4.dp)
|
||||
) {
|
||||
values.forEachIndexed { index, item ->
|
||||
Column(
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.container(
|
||||
shape = ShapeDefaults.byIndex(
|
||||
index = index,
|
||||
size = values.size
|
||||
),
|
||||
resultPadding = 0.dp
|
||||
)
|
||||
) {
|
||||
TitleItem(
|
||||
text = stringResource(item.getTitle()),
|
||||
icon = item.getIcon()
|
||||
)
|
||||
Text(
|
||||
text = stringResource(item.getSubtitle()),
|
||||
modifier = Modifier.padding(
|
||||
start = 16.dp,
|
||||
end = 16.dp,
|
||||
bottom = 16.dp
|
||||
),
|
||||
fontSize = 14.sp,
|
||||
lineHeight = 18.sp
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
visible = isSheetVisible,
|
||||
onDismiss = {
|
||||
isSheetVisible = it
|
||||
},
|
||||
title = {
|
||||
TitleItem(text = stringResource(R.string.draw_mode))
|
||||
},
|
||||
confirmButton = {
|
||||
EnhancedButton(
|
||||
containerColor = MaterialTheme.colorScheme.secondaryContainer,
|
||||
onClick = { isSheetVisible = false }
|
||||
) {
|
||||
AutoSizeText(stringResource(R.string.close))
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
private fun DrawLineStyle.getSubtitle(): Int = when (this) {
|
||||
is DrawLineStyle.Dashed -> R.string.dashed_sub
|
||||
DrawLineStyle.DotDashed -> R.string.dot_dashed_sub
|
||||
DrawLineStyle.None -> R.string.defaultt_sub
|
||||
is DrawLineStyle.Stamped<*> -> R.string.stamped_sub
|
||||
is DrawLineStyle.ZigZag -> R.string.zigzag_sub
|
||||
}
|
||||
|
||||
private fun DrawLineStyle.getTitle(): Int = when (this) {
|
||||
is DrawLineStyle.Dashed -> R.string.dashed
|
||||
DrawLineStyle.DotDashed -> R.string.dot_dashed
|
||||
DrawLineStyle.None -> R.string.defaultt
|
||||
is DrawLineStyle.Stamped<*> -> R.string.stamped
|
||||
is DrawLineStyle.ZigZag -> R.string.zigzag
|
||||
}
|
||||
|
||||
private fun DrawLineStyle.getIcon(): ImageVector = when (this) {
|
||||
is DrawLineStyle.Dashed -> Icons.Rounded.DashedLine
|
||||
DrawLineStyle.DotDashed -> Icons.Rounded.DotDashedLine
|
||||
DrawLineStyle.None -> Icons.Rounded.BoldLine
|
||||
is DrawLineStyle.Stamped<*> -> Icons.Rounded.StampedLine
|
||||
is DrawLineStyle.ZigZag -> Icons.Rounded.ZigzagLine
|
||||
}
|
||||
+261
@@ -0,0 +1,261 @@
|
||||
/*
|
||||
* 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.draw.presentation.components
|
||||
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.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.vector.ImageVector
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import com.t8rin.imagetoolbox.core.domain.model.Pt
|
||||
import com.t8rin.imagetoolbox.core.filters.presentation.widget.FilterTemplateCreationSheetComponent
|
||||
import com.t8rin.imagetoolbox.core.filters.presentation.widget.addFilters.AddFiltersSheetComponent
|
||||
import com.t8rin.imagetoolbox.core.resources.Icons
|
||||
import com.t8rin.imagetoolbox.core.resources.R
|
||||
import com.t8rin.imagetoolbox.core.resources.icons.AutoFixHigh
|
||||
import com.t8rin.imagetoolbox.core.resources.icons.BlurCircular
|
||||
import com.t8rin.imagetoolbox.core.resources.icons.Cube
|
||||
import com.t8rin.imagetoolbox.core.resources.icons.Healing
|
||||
import com.t8rin.imagetoolbox.core.resources.icons.Highlighter
|
||||
import com.t8rin.imagetoolbox.core.resources.icons.Image
|
||||
import com.t8rin.imagetoolbox.core.resources.icons.MeshGradient
|
||||
import com.t8rin.imagetoolbox.core.resources.icons.NeonBrush
|
||||
import com.t8rin.imagetoolbox.core.resources.icons.Pen
|
||||
import com.t8rin.imagetoolbox.core.resources.icons.TextFormat
|
||||
import com.t8rin.imagetoolbox.core.ui.widget.buttons.SupportingButton
|
||||
import com.t8rin.imagetoolbox.core.ui.widget.enhanced.EnhancedButton
|
||||
import com.t8rin.imagetoolbox.core.ui.widget.enhanced.EnhancedButtonGroup
|
||||
import com.t8rin.imagetoolbox.core.ui.widget.enhanced.EnhancedModalBottomSheet
|
||||
import com.t8rin.imagetoolbox.core.ui.widget.enhanced.enhancedVerticalScroll
|
||||
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.AutoSizeText
|
||||
import com.t8rin.imagetoolbox.core.ui.widget.text.TitleItem
|
||||
import com.t8rin.imagetoolbox.feature.draw.domain.DrawMode
|
||||
import com.t8rin.imagetoolbox.feature.draw.presentation.components.element.CustomPathEffectParamsSelector
|
||||
import com.t8rin.imagetoolbox.feature.draw.presentation.components.element.ImageParamsSelector
|
||||
import com.t8rin.imagetoolbox.feature.draw.presentation.components.element.PixelationParamsSelector
|
||||
import com.t8rin.imagetoolbox.feature.draw.presentation.components.element.PrivacyBlurParamsSelector
|
||||
import com.t8rin.imagetoolbox.feature.draw.presentation.components.element.SpotHealParamsSelector
|
||||
import com.t8rin.imagetoolbox.feature.draw.presentation.components.element.TextParamsSelector
|
||||
import com.t8rin.imagetoolbox.feature.draw.presentation.components.element.WarpParamsSelector
|
||||
|
||||
@Composable
|
||||
fun DrawModeSelector(
|
||||
addFiltersSheetComponent: AddFiltersSheetComponent,
|
||||
filterTemplateCreationSheetComponent: FilterTemplateCreationSheetComponent,
|
||||
modifier: Modifier,
|
||||
value: DrawMode,
|
||||
strokeWidth: Pt,
|
||||
onValueChange: (DrawMode) -> Unit,
|
||||
values: List<DrawMode> = DrawMode.entries
|
||||
) {
|
||||
var isSheetVisible by rememberSaveable { mutableStateOf(false) }
|
||||
|
||||
LaunchedEffect(values, value) {
|
||||
if (values.find { it::class.isInstance(value) } == null) {
|
||||
values.firstOrNull()?.let { onValueChange(it) }
|
||||
}
|
||||
}
|
||||
|
||||
Column(
|
||||
modifier = modifier
|
||||
.container(ShapeDefaults.extraLarge),
|
||||
horizontalAlignment = Alignment.CenterHorizontally
|
||||
) {
|
||||
EnhancedButtonGroup(
|
||||
enabled = true,
|
||||
itemCount = values.size,
|
||||
title = {
|
||||
Text(
|
||||
text = stringResource(R.string.draw_mode),
|
||||
textAlign = TextAlign.Center,
|
||||
fontWeight = FontWeight.Medium
|
||||
)
|
||||
Spacer(modifier = Modifier.width(8.dp))
|
||||
SupportingButton(
|
||||
onClick = {
|
||||
isSheetVisible = true
|
||||
}
|
||||
)
|
||||
},
|
||||
selectedIndex = values.indexOfFirst {
|
||||
value::class.isInstance(it)
|
||||
},
|
||||
itemContent = {
|
||||
Icon(
|
||||
imageVector = values[it].getIcon(),
|
||||
contentDescription = null
|
||||
)
|
||||
},
|
||||
onIndexChange = {
|
||||
onValueChange(values[it])
|
||||
}
|
||||
)
|
||||
|
||||
WarpParamsSelector(
|
||||
value = value,
|
||||
onValueChange = onValueChange
|
||||
)
|
||||
|
||||
SpotHealParamsSelector(
|
||||
value = value,
|
||||
onValueChange = onValueChange
|
||||
)
|
||||
|
||||
PrivacyBlurParamsSelector(
|
||||
value = value,
|
||||
onValueChange = onValueChange
|
||||
)
|
||||
|
||||
PixelationParamsSelector(
|
||||
value = value,
|
||||
onValueChange = onValueChange
|
||||
)
|
||||
|
||||
TextParamsSelector(
|
||||
value = value,
|
||||
onValueChange = onValueChange
|
||||
)
|
||||
|
||||
ImageParamsSelector(
|
||||
value = value,
|
||||
onValueChange = onValueChange,
|
||||
strokeWidth = strokeWidth
|
||||
)
|
||||
|
||||
CustomPathEffectParamsSelector(
|
||||
value = value,
|
||||
onValueChange = onValueChange,
|
||||
addFiltersSheetComponent = addFiltersSheetComponent,
|
||||
filterTemplateCreationSheetComponent = filterTemplateCreationSheetComponent
|
||||
)
|
||||
}
|
||||
EnhancedModalBottomSheet(
|
||||
sheetContent = {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.enhancedVerticalScroll(rememberScrollState())
|
||||
.padding(8.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(4.dp)
|
||||
) {
|
||||
values.forEachIndexed { index, item ->
|
||||
Column(
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.container(
|
||||
shape = ShapeDefaults.byIndex(
|
||||
index = index,
|
||||
size = values.size
|
||||
),
|
||||
resultPadding = 0.dp
|
||||
)
|
||||
) {
|
||||
TitleItem(
|
||||
text = stringResource(item.getTitle()),
|
||||
icon = item.getIcon()
|
||||
)
|
||||
Text(
|
||||
text = stringResource(item.getSubtitle()),
|
||||
modifier = Modifier.padding(
|
||||
start = 16.dp,
|
||||
end = 16.dp,
|
||||
bottom = 16.dp
|
||||
),
|
||||
fontSize = 14.sp,
|
||||
lineHeight = 18.sp
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
visible = isSheetVisible,
|
||||
onDismiss = {
|
||||
isSheetVisible = it
|
||||
},
|
||||
title = {
|
||||
TitleItem(text = stringResource(R.string.draw_mode))
|
||||
},
|
||||
confirmButton = {
|
||||
EnhancedButton(
|
||||
containerColor = MaterialTheme.colorScheme.secondaryContainer,
|
||||
onClick = { isSheetVisible = false }
|
||||
) {
|
||||
AutoSizeText(stringResource(R.string.close))
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
private fun DrawMode.getSubtitle(): Int = when (this) {
|
||||
is DrawMode.Highlighter -> R.string.highlighter_sub
|
||||
is DrawMode.Neon -> R.string.neon_sub
|
||||
is DrawMode.Pen -> R.string.pen_sub
|
||||
is DrawMode.PathEffect.PrivacyBlur -> R.string.privacy_blur_sub
|
||||
is DrawMode.PathEffect.Pixelation -> R.string.pixelation_sub
|
||||
is DrawMode.Text -> R.string.draw_text_sub
|
||||
is DrawMode.Image -> R.string.draw_mode_image_sub
|
||||
is DrawMode.PathEffect.Custom -> R.string.draw_filter_sub
|
||||
is DrawMode.SpotHeal -> R.string.spot_heal_sub
|
||||
is DrawMode.Warp -> R.string.warp_sub
|
||||
}
|
||||
|
||||
private fun DrawMode.getTitle(): Int = when (this) {
|
||||
is DrawMode.Highlighter -> R.string.highlighter
|
||||
is DrawMode.Neon -> R.string.neon
|
||||
is DrawMode.Pen -> R.string.pen
|
||||
is DrawMode.PathEffect.PrivacyBlur -> R.string.privacy_blur
|
||||
is DrawMode.PathEffect.Pixelation -> R.string.pixelation
|
||||
is DrawMode.Text -> R.string.text
|
||||
is DrawMode.Image -> R.string.image
|
||||
is DrawMode.PathEffect.Custom -> R.string.filter
|
||||
is DrawMode.SpotHeal -> R.string.spot_heal
|
||||
is DrawMode.Warp -> R.string.warp
|
||||
}
|
||||
|
||||
private fun DrawMode.getIcon(): ImageVector = when (this) {
|
||||
is DrawMode.Highlighter -> Icons.Outlined.Highlighter
|
||||
is DrawMode.Neon -> Icons.Outlined.NeonBrush
|
||||
is DrawMode.Pen -> Icons.Outlined.Pen
|
||||
is DrawMode.PathEffect.PrivacyBlur -> Icons.Outlined.BlurCircular
|
||||
is DrawMode.PathEffect.Pixelation -> Icons.Outlined.Cube
|
||||
is DrawMode.Text -> Icons.Rounded.TextFormat
|
||||
is DrawMode.Image -> Icons.Outlined.Image
|
||||
is DrawMode.PathEffect.Custom -> Icons.Outlined.AutoFixHigh
|
||||
is DrawMode.SpotHeal -> Icons.Outlined.Healing
|
||||
is DrawMode.Warp -> Icons.Outlined.MeshGradient
|
||||
}
|
||||
+174
@@ -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.draw.presentation.components
|
||||
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
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.LaunchedEffect
|
||||
import androidx.compose.runtime.derivedStateOf
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.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.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.t8rin.imagetoolbox.core.resources.R
|
||||
import com.t8rin.imagetoolbox.core.ui.widget.buttons.SupportingButton
|
||||
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.container
|
||||
import com.t8rin.imagetoolbox.feature.draw.domain.DrawMode
|
||||
import com.t8rin.imagetoolbox.feature.draw.domain.DrawPathMode
|
||||
import com.t8rin.imagetoolbox.feature.draw.presentation.components.element.ArrowParamsSelector
|
||||
import com.t8rin.imagetoolbox.feature.draw.presentation.components.element.DrawPathModeInfoSheet
|
||||
import com.t8rin.imagetoolbox.feature.draw.presentation.components.element.FloodFillParamsSelector
|
||||
import com.t8rin.imagetoolbox.feature.draw.presentation.components.element.OvalParamsSelector
|
||||
import com.t8rin.imagetoolbox.feature.draw.presentation.components.element.PolygonParamsSelector
|
||||
import com.t8rin.imagetoolbox.feature.draw.presentation.components.element.RectParamsSelector
|
||||
import com.t8rin.imagetoolbox.feature.draw.presentation.components.element.SprayParamsSelector
|
||||
import com.t8rin.imagetoolbox.feature.draw.presentation.components.element.StarParamsSelector
|
||||
import com.t8rin.imagetoolbox.feature.draw.presentation.components.element.TriangleParamsSelector
|
||||
import com.t8rin.imagetoolbox.feature.draw.presentation.components.utils.getIcon
|
||||
import com.t8rin.imagetoolbox.feature.draw.presentation.components.utils.saveState
|
||||
|
||||
@Composable
|
||||
fun DrawPathModeSelector(
|
||||
modifier: Modifier,
|
||||
values: List<DrawPathMode> = DrawPathMode.entries,
|
||||
value: DrawPathMode,
|
||||
onValueChange: (DrawPathMode) -> Unit,
|
||||
drawMode: DrawMode,
|
||||
containerColor: Color = Color.Unspecified
|
||||
) {
|
||||
var isSheetVisible by rememberSaveable { mutableStateOf(false) }
|
||||
|
||||
LaunchedEffect(value, values) {
|
||||
if (values.find { it::class.isInstance(value) } == null) {
|
||||
values.firstOrNull()?.let { onValueChange(it) }
|
||||
}
|
||||
}
|
||||
|
||||
Column(
|
||||
modifier = modifier
|
||||
.container(
|
||||
shape = ShapeDefaults.extraLarge,
|
||||
color = containerColor
|
||||
),
|
||||
horizontalAlignment = Alignment.CenterHorizontally
|
||||
) {
|
||||
EnhancedButtonGroup(
|
||||
enabled = true,
|
||||
itemCount = values.size,
|
||||
title = {
|
||||
Text(
|
||||
text = stringResource(R.string.draw_path_mode),
|
||||
textAlign = TextAlign.Center,
|
||||
fontWeight = FontWeight.Medium
|
||||
)
|
||||
Spacer(modifier = Modifier.width(8.dp))
|
||||
SupportingButton(
|
||||
onClick = {
|
||||
isSheetVisible = true
|
||||
}
|
||||
)
|
||||
},
|
||||
selectedIndex = remember(values, value) {
|
||||
derivedStateOf {
|
||||
values.indexOfFirst {
|
||||
value::class.isInstance(it)
|
||||
}
|
||||
}
|
||||
}.value,
|
||||
activeButtonColor = MaterialTheme.colorScheme.surfaceContainerHighest,
|
||||
itemContent = {
|
||||
Icon(
|
||||
imageVector = values[it].getIcon(),
|
||||
contentDescription = null
|
||||
)
|
||||
},
|
||||
onIndexChange = {
|
||||
onValueChange(values[it].saveState(value))
|
||||
}
|
||||
)
|
||||
|
||||
val canChangeFillColor =
|
||||
value is DrawPathMode.Outlined && (drawMode is DrawMode.Pen || drawMode is DrawMode.Highlighter || drawMode is DrawMode.Neon)
|
||||
|
||||
PolygonParamsSelector(
|
||||
value = value,
|
||||
onValueChange = onValueChange,
|
||||
canChangeFillColor = canChangeFillColor
|
||||
)
|
||||
|
||||
StarParamsSelector(
|
||||
value = value,
|
||||
onValueChange = onValueChange,
|
||||
canChangeFillColor = canChangeFillColor
|
||||
)
|
||||
|
||||
RectParamsSelector(
|
||||
value = value,
|
||||
onValueChange = onValueChange,
|
||||
canChangeFillColor = canChangeFillColor
|
||||
)
|
||||
|
||||
OvalParamsSelector(
|
||||
value = value,
|
||||
onValueChange = onValueChange,
|
||||
canChangeFillColor = canChangeFillColor
|
||||
)
|
||||
|
||||
TriangleParamsSelector(
|
||||
value = value,
|
||||
onValueChange = onValueChange,
|
||||
canChangeFillColor = canChangeFillColor
|
||||
)
|
||||
|
||||
ArrowParamsSelector(
|
||||
value = value,
|
||||
onValueChange = onValueChange
|
||||
)
|
||||
|
||||
FloodFillParamsSelector(
|
||||
value = value,
|
||||
onValueChange = onValueChange
|
||||
)
|
||||
|
||||
SprayParamsSelector(
|
||||
value = value,
|
||||
onValueChange = onValueChange
|
||||
)
|
||||
}
|
||||
|
||||
DrawPathModeInfoSheet(
|
||||
visible = isSheetVisible,
|
||||
onDismiss = { isSheetVisible = false },
|
||||
values = values
|
||||
)
|
||||
}
|
||||
+60
@@ -0,0 +1,60 @@
|
||||
/*
|
||||
* 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.draw.presentation.components
|
||||
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import com.t8rin.imagetoolbox.core.resources.Icons
|
||||
import androidx.compose.runtime.Composable
|
||||
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.colors.util.roundToTwoDigits
|
||||
import com.t8rin.imagetoolbox.core.resources.R
|
||||
import com.t8rin.imagetoolbox.core.resources.icons.LineWeight
|
||||
import com.t8rin.imagetoolbox.core.ui.widget.enhanced.EnhancedSliderItem
|
||||
import com.t8rin.imagetoolbox.core.ui.widget.modifier.ShapeDefaults
|
||||
|
||||
@Composable
|
||||
fun LineWidthSelector(
|
||||
modifier: Modifier,
|
||||
value: Float,
|
||||
title: String = stringResource(R.string.line_width),
|
||||
valueRange: ClosedFloatingPointRange<Float> = 1f..100f,
|
||||
color: Color = Color.Unspecified,
|
||||
onValueChange: (Float) -> Unit
|
||||
) {
|
||||
EnhancedSliderItem(
|
||||
modifier = modifier,
|
||||
value = value,
|
||||
containerColor = color,
|
||||
icon = Icons.Rounded.LineWeight,
|
||||
title = title,
|
||||
valueSuffix = " Pt",
|
||||
sliderModifier = Modifier
|
||||
.padding(top = 14.dp, start = 12.dp, end = 12.dp, bottom = 10.dp),
|
||||
valueRange = valueRange,
|
||||
internalStateTransformation = {
|
||||
it.roundToTwoDigits()
|
||||
},
|
||||
onValueChange = {
|
||||
onValueChange(it.roundToTwoDigits())
|
||||
},
|
||||
shape = ShapeDefaults.extraLarge
|
||||
)
|
||||
}
|
||||
+67
@@ -0,0 +1,67 @@
|
||||
/*
|
||||
* 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.draw.presentation.components
|
||||
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import com.t8rin.imagetoolbox.core.resources.Icons
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
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.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
|
||||
|
||||
@Composable
|
||||
fun OpenColorPickerCard(
|
||||
onOpen: () -> Unit,
|
||||
modifier: Modifier = Modifier
|
||||
.padding(horizontal = 16.dp)
|
||||
) {
|
||||
Row(
|
||||
modifier = modifier
|
||||
.container(
|
||||
resultPadding = 0.dp,
|
||||
color = MaterialTheme.colorScheme.mixedContainer.copy(0.7f),
|
||||
shape = ShapeDefaults.extraLarge
|
||||
)
|
||||
.hapticsClickable(onClick = onOpen)
|
||||
.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
|
||||
)
|
||||
}
|
||||
}
|
||||
+62
@@ -0,0 +1,62 @@
|
||||
/*
|
||||
* 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.draw.presentation.components
|
||||
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import com.t8rin.imagetoolbox.core.resources.Icons
|
||||
import androidx.compose.runtime.Composable
|
||||
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.colors.util.roundToTwoDigits
|
||||
import com.t8rin.imagetoolbox.core.resources.R
|
||||
import com.t8rin.imagetoolbox.core.resources.icons.Cube
|
||||
import com.t8rin.imagetoolbox.core.ui.widget.enhanced.EnhancedSliderItem
|
||||
import com.t8rin.imagetoolbox.core.ui.widget.modifier.ShapeDefaults
|
||||
|
||||
@Composable
|
||||
fun PixelSizeSelector(
|
||||
modifier: Modifier,
|
||||
value: Float,
|
||||
onValueChange: (Float) -> Unit,
|
||||
color: Color = Color.Unspecified
|
||||
) {
|
||||
EnhancedSliderItem(
|
||||
modifier = modifier,
|
||||
value = value,
|
||||
title = stringResource(R.string.pixel_size),
|
||||
sliderModifier = Modifier
|
||||
.padding(
|
||||
top = 14.dp,
|
||||
start = 12.dp,
|
||||
end = 12.dp,
|
||||
bottom = 10.dp
|
||||
),
|
||||
icon = Icons.Outlined.Cube,
|
||||
valueRange = 10f..75f,
|
||||
internalStateTransformation = {
|
||||
it.roundToTwoDigits()
|
||||
},
|
||||
onValueChange = {
|
||||
onValueChange(it.roundToTwoDigits())
|
||||
},
|
||||
shape = ShapeDefaults.default,
|
||||
containerColor = color
|
||||
)
|
||||
}
|
||||
+52
@@ -0,0 +1,52 @@
|
||||
/*
|
||||
* 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.draw.presentation.components
|
||||
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.Path
|
||||
import com.t8rin.imagetoolbox.core.domain.model.IntegerSize
|
||||
import com.t8rin.imagetoolbox.core.domain.model.Pt
|
||||
import com.t8rin.imagetoolbox.feature.draw.domain.DrawLineStyle
|
||||
import com.t8rin.imagetoolbox.feature.draw.domain.DrawMode
|
||||
import com.t8rin.imagetoolbox.feature.draw.domain.DrawPathMode
|
||||
import com.t8rin.imagetoolbox.feature.draw.domain.PathPaint
|
||||
|
||||
data class UiPathPaint(
|
||||
override val path: Path,
|
||||
override val strokeWidth: Pt,
|
||||
override val brushSoftness: Pt,
|
||||
override val drawColor: Color = Color.Transparent,
|
||||
override val isErasing: Boolean,
|
||||
override val drawMode: DrawMode = DrawMode.Pen,
|
||||
override val canvasSize: IntegerSize,
|
||||
override val drawPathMode: DrawPathMode = DrawPathMode.Free,
|
||||
override val drawLineStyle: DrawLineStyle = DrawLineStyle.None
|
||||
) : PathPaint<Path, Color>
|
||||
|
||||
|
||||
fun PathPaint<Path, Color>.toUiPathPaint() = UiPathPaint(
|
||||
path = path,
|
||||
strokeWidth = strokeWidth,
|
||||
brushSoftness = brushSoftness,
|
||||
drawColor = drawColor,
|
||||
isErasing = isErasing,
|
||||
drawMode = drawMode,
|
||||
canvasSize = canvasSize,
|
||||
drawPathMode = drawPathMode,
|
||||
drawLineStyle = drawLineStyle
|
||||
)
|
||||
+395
@@ -0,0 +1,395 @@
|
||||
/*
|
||||
* 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.draw.presentation.components
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
import android.graphics.Bitmap
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.derivedStateOf
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableFloatStateOf
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.geometry.Offset
|
||||
import androidx.compose.ui.graphics.BlendMode
|
||||
import androidx.compose.ui.graphics.Canvas
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.ImageBitmap
|
||||
import androidx.compose.ui.graphics.Paint
|
||||
import androidx.compose.ui.graphics.PaintingStyle
|
||||
import androidx.compose.ui.graphics.StrokeCap
|
||||
import androidx.compose.ui.graphics.StrokeJoin
|
||||
import androidx.compose.ui.graphics.asAndroidBitmap
|
||||
import androidx.compose.ui.graphics.asAndroidPath
|
||||
import androidx.compose.ui.graphics.asComposePaint
|
||||
import androidx.compose.ui.graphics.asComposePath
|
||||
import androidx.compose.ui.graphics.asImageBitmap
|
||||
import androidx.compose.ui.graphics.nativeCanvas
|
||||
import androidx.compose.ui.graphics.nativePaint
|
||||
import androidx.compose.ui.graphics.toArgb
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.IntSize
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.core.graphics.applyCanvas
|
||||
import androidx.core.graphics.createBitmap
|
||||
import com.t8rin.imagetoolbox.core.domain.model.ImageModel
|
||||
import com.t8rin.imagetoolbox.core.domain.model.IntegerSize
|
||||
import com.t8rin.imagetoolbox.core.filters.domain.model.Filter
|
||||
import com.t8rin.imagetoolbox.core.filters.domain.model.createFilter
|
||||
import com.t8rin.imagetoolbox.core.filters.domain.model.enums.SpotHealMode
|
||||
import com.t8rin.imagetoolbox.core.ui.utils.helper.scaleToFitCanvas
|
||||
import com.t8rin.imagetoolbox.core.ui.widget.dialogs.LoadingDialog
|
||||
import com.t8rin.imagetoolbox.core.ui.widget.text.AutoSizeText
|
||||
import com.t8rin.imagetoolbox.core.utils.toImageModel
|
||||
import com.t8rin.imagetoolbox.feature.draw.domain.DrawMode
|
||||
import com.t8rin.imagetoolbox.feature.draw.domain.DrawPathMode
|
||||
import com.t8rin.imagetoolbox.feature.draw.presentation.components.utils.clipBitmap
|
||||
import com.t8rin.imagetoolbox.feature.draw.presentation.components.utils.drawRepeatedImageOnPath
|
||||
import com.t8rin.imagetoolbox.feature.draw.presentation.components.utils.drawRepeatedTextOnPath
|
||||
import com.t8rin.imagetoolbox.feature.draw.presentation.components.utils.overlay
|
||||
import com.t8rin.imagetoolbox.feature.draw.presentation.components.utils.pathEffectPaint
|
||||
import com.t8rin.imagetoolbox.feature.draw.presentation.components.utils.rememberPaint
|
||||
import com.t8rin.imagetoolbox.feature.draw.presentation.components.utils.transformationsForMode
|
||||
import com.t8rin.trickle.WarpBrush
|
||||
import com.t8rin.trickle.WarpEngine
|
||||
import com.t8rin.trickle.WarpMode
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.isActive
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import kotlin.math.roundToInt
|
||||
import android.graphics.Paint as AndroidPaint
|
||||
|
||||
@SuppressLint("ComposableNaming")
|
||||
@Composable
|
||||
internal fun Canvas.UiPathPaintCanvasAction(
|
||||
uiPathPaint: UiPathPaint,
|
||||
invalidations: Int,
|
||||
onInvalidate: () -> Unit,
|
||||
canvasSize: IntegerSize,
|
||||
pathsCount: Int,
|
||||
backgroundColor: Color,
|
||||
drawImageBitmap: ImageBitmap,
|
||||
drawBitmap: ImageBitmap,
|
||||
onClearDrawPath: () -> Unit,
|
||||
onClearWarpDrawPath: (Long) -> Unit,
|
||||
onRequestFiltering: suspend (Bitmap, List<Filter<*>>) -> Bitmap?,
|
||||
spotHealCache: Map<Int, Bitmap>,
|
||||
onCacheSpotHealPathResult: (Int, Bitmap) -> Unit,
|
||||
onCancel: (UiPathPaint) -> Unit,
|
||||
scope: CoroutineScope
|
||||
) = with(nativeCanvas) {
|
||||
val (nonScaledPath, strokeWidth, brushSoftness, drawColor, isEraserOn, drawMode, size, drawPathMode, drawLineStyle) = uiPathPaint
|
||||
|
||||
val path by remember(nonScaledPath, canvasSize, size) {
|
||||
derivedStateOf {
|
||||
nonScaledPath.scaleToFitCanvas(
|
||||
currentSize = canvasSize,
|
||||
oldSize = size
|
||||
).asAndroidPath()
|
||||
}
|
||||
}
|
||||
|
||||
if (drawMode is DrawMode.PathEffect && !isEraserOn) {
|
||||
var shaderSource by remember(backgroundColor) {
|
||||
mutableStateOf<ImageBitmap?>(null)
|
||||
}
|
||||
LaunchedEffect(shaderSource, invalidations) {
|
||||
withContext(Dispatchers.Default) {
|
||||
if (shaderSource == null || invalidations <= pathsCount) {
|
||||
shaderSource = onRequestFiltering(
|
||||
drawImageBitmap.overlay(drawBitmap)
|
||||
.asAndroidBitmap(),
|
||||
transformationsForMode(
|
||||
drawMode = drawMode,
|
||||
canvasSize = canvasSize
|
||||
)
|
||||
)?.asImageBitmap()?.clipBitmap(
|
||||
path = path.asComposePath(),
|
||||
paint = pathEffectPaint(
|
||||
strokeWidth = strokeWidth,
|
||||
drawPathMode = drawPathMode,
|
||||
canvasSize = canvasSize
|
||||
).asComposePaint()
|
||||
)?.also {
|
||||
it.prepareToDraw()
|
||||
onInvalidate()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if (shaderSource != null) {
|
||||
LaunchedEffect(shaderSource) {
|
||||
onClearDrawPath()
|
||||
}
|
||||
val imagePaint = remember { Paint() }
|
||||
drawImage(
|
||||
image = shaderSource!!,
|
||||
topLeftOffset = Offset.Zero,
|
||||
paint = imagePaint
|
||||
)
|
||||
}
|
||||
} else if (drawMode is DrawMode.SpotHeal && !isEraserOn) {
|
||||
val cacheKey = uiPathPaint.hashCode()
|
||||
|
||||
val paint = remember(uiPathPaint, canvasSize) {
|
||||
val isSharpEdge = drawPathMode.isSharpEdge
|
||||
val isFilled = drawPathMode.isFilled
|
||||
val stroke = strokeWidth.toPx(canvasSize)
|
||||
|
||||
Paint().apply {
|
||||
if (isFilled) {
|
||||
style = PaintingStyle.Fill
|
||||
} else {
|
||||
style = PaintingStyle.Stroke
|
||||
this.strokeWidth = stroke
|
||||
if (isSharpEdge) {
|
||||
strokeCap = StrokeCap.Square
|
||||
} else {
|
||||
strokeCap = StrokeCap.Round
|
||||
strokeJoin = StrokeJoin.Round
|
||||
}
|
||||
}
|
||||
|
||||
color = Color.White
|
||||
}
|
||||
}
|
||||
|
||||
var isLoading by remember {
|
||||
mutableStateOf(false)
|
||||
}
|
||||
|
||||
var progress by remember {
|
||||
mutableFloatStateOf(0f)
|
||||
}
|
||||
|
||||
var shaderSource by remember(cacheKey, backgroundColor) {
|
||||
mutableStateOf(spotHealCache[cacheKey]?.asImageBitmap())
|
||||
}
|
||||
LaunchedEffect(shaderSource, invalidations) {
|
||||
withContext(Dispatchers.Default) {
|
||||
if (shaderSource == null) {
|
||||
isLoading = true
|
||||
val job = launch {
|
||||
while (progress < 0.5f && isActive && isLoading) {
|
||||
progress += 0.01f
|
||||
delay(100)
|
||||
}
|
||||
while (progress < 0.75f && isActive && isLoading) {
|
||||
progress += 0.0025f
|
||||
delay(100)
|
||||
}
|
||||
while (progress < 1f && isActive && isLoading) {
|
||||
progress += 0.0025f
|
||||
delay(500)
|
||||
}
|
||||
}
|
||||
|
||||
shaderSource = withContext(Dispatchers.IO) {
|
||||
onRequestFiltering(
|
||||
drawImageBitmap.overlay(drawBitmap).asAndroidBitmap(),
|
||||
listOf(
|
||||
createFilter<Pair<ImageModel, SpotHealMode>, Filter.SpotHeal>(
|
||||
Pair(
|
||||
createBitmap(
|
||||
width = canvasSize.width,
|
||||
height = canvasSize.height
|
||||
).applyCanvas {
|
||||
drawColor(Color.Black.toArgb())
|
||||
drawPath(
|
||||
path,
|
||||
paint.nativePaint
|
||||
)
|
||||
}.toImageModel(),
|
||||
drawMode.mode
|
||||
)
|
||||
)
|
||||
)
|
||||
)?.asImageBitmap()?.clipBitmap(
|
||||
path = path.asComposePath(),
|
||||
paint = paint.apply {
|
||||
blendMode = BlendMode.Clear
|
||||
}
|
||||
)?.also {
|
||||
onCacheSpotHealPathResult(cacheKey, it.asAndroidBitmap())
|
||||
it.prepareToDraw()
|
||||
onInvalidate()
|
||||
}
|
||||
}
|
||||
isLoading = false
|
||||
job.cancel()
|
||||
progress = 0f
|
||||
}
|
||||
}
|
||||
}
|
||||
if (shaderSource != null) {
|
||||
LaunchedEffect(shaderSource) {
|
||||
onClearDrawPath()
|
||||
onInvalidate()
|
||||
}
|
||||
val imagePaint = remember { Paint() }
|
||||
drawImageRect(
|
||||
image = shaderSource!!,
|
||||
dstSize = IntSize(canvasSize.width, canvasSize.height),
|
||||
paint = imagePaint
|
||||
)
|
||||
}
|
||||
|
||||
LoadingDialog(
|
||||
visible = isLoading,
|
||||
onCancelLoading = {
|
||||
scope.launch {
|
||||
isLoading = false
|
||||
delay(400)
|
||||
onClearDrawPath()
|
||||
onCancel(uiPathPaint)
|
||||
onInvalidate()
|
||||
}
|
||||
},
|
||||
canCancel = true,
|
||||
progress = { progress },
|
||||
loaderSize = 72.dp,
|
||||
additionalContent = {
|
||||
AutoSizeText(
|
||||
text = "${(progress * 100).roundToInt()}%",
|
||||
maxLines = 1,
|
||||
fontWeight = FontWeight.Medium,
|
||||
modifier = Modifier.width(it * 0.8f),
|
||||
textAlign = TextAlign.Center
|
||||
)
|
||||
}
|
||||
)
|
||||
} else if (drawMode is DrawMode.Warp && !isEraserOn) {
|
||||
var warpedBitmap by remember(uiPathPaint, canvasSize) {
|
||||
mutableStateOf<ImageBitmap?>(null)
|
||||
}
|
||||
|
||||
LaunchedEffect(warpedBitmap, invalidations) {
|
||||
if (warpedBitmap == null || invalidations <= pathsCount) {
|
||||
val source = drawImageBitmap
|
||||
.overlay(drawBitmap)
|
||||
.asAndroidBitmap()
|
||||
|
||||
withContext(Dispatchers.Default) {
|
||||
val engine = WarpEngine(source)
|
||||
|
||||
try {
|
||||
drawMode.strokes.forEach { warp ->
|
||||
val stroke = warp.scaleToFitCanvas(
|
||||
currentSize = canvasSize,
|
||||
oldSize = size
|
||||
)
|
||||
engine.applyStroke(
|
||||
fromX = stroke.fromX,
|
||||
fromY = stroke.fromY,
|
||||
toX = stroke.toX,
|
||||
toY = stroke.toY,
|
||||
brush = WarpBrush(
|
||||
radius = strokeWidth.toPx(canvasSize),
|
||||
strength = drawMode.strength,
|
||||
hardness = drawMode.hardness
|
||||
),
|
||||
mode = WarpMode.valueOf(drawMode.warpMode.name)
|
||||
)
|
||||
}
|
||||
|
||||
warpedBitmap = engine
|
||||
.render()
|
||||
.asImageBitmap().also {
|
||||
it.prepareToDraw()
|
||||
onInvalidate()
|
||||
}
|
||||
} finally {
|
||||
engine.release()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
warpedBitmap?.let {
|
||||
LaunchedEffect(warpedBitmap) {
|
||||
onClearWarpDrawPath(drawMode.previewClearToken)
|
||||
}
|
||||
val imagePaint = remember { Paint() }
|
||||
drawImage(
|
||||
image = warpedBitmap!!,
|
||||
topLeftOffset = Offset.Zero,
|
||||
paint = imagePaint
|
||||
)
|
||||
}
|
||||
} else {
|
||||
val pathPaint by rememberPaint(
|
||||
strokeWidth = strokeWidth,
|
||||
isEraserOn = isEraserOn,
|
||||
drawColor = drawColor,
|
||||
brushSoftness = brushSoftness,
|
||||
drawMode = drawMode,
|
||||
canvasSize = canvasSize,
|
||||
drawPathMode = drawPathMode,
|
||||
drawLineStyle = drawLineStyle
|
||||
)
|
||||
if (drawMode is DrawMode.Text && !isEraserOn) {
|
||||
if (drawMode.isRepeated) {
|
||||
drawRepeatedTextOnPath(
|
||||
text = drawMode.text,
|
||||
path = path,
|
||||
paint = pathPaint,
|
||||
interval = drawMode.repeatingInterval.toPx(canvasSize)
|
||||
)
|
||||
} else {
|
||||
drawTextOnPath(drawMode.text, path, 0f, 0f, pathPaint)
|
||||
}
|
||||
} else if (drawMode is DrawMode.Image && !isEraserOn) {
|
||||
drawRepeatedImageOnPath(
|
||||
drawMode = drawMode,
|
||||
strokeWidth = strokeWidth,
|
||||
canvasSize = canvasSize,
|
||||
path = path,
|
||||
paint = pathPaint,
|
||||
invalidations = invalidations
|
||||
)
|
||||
} else if (drawPathMode is DrawPathMode.Outlined) {
|
||||
drawPathMode.fillColor?.let { fillColor ->
|
||||
val filledPaint = remember(fillColor, pathPaint) {
|
||||
AndroidPaint().apply {
|
||||
set(pathPaint)
|
||||
style = AndroidPaint.Style.FILL
|
||||
color = fillColor.colorInt
|
||||
if (Color(fillColor.colorInt).alpha == 1f) {
|
||||
alpha =
|
||||
(drawColor.alpha * 255).roundToInt().coerceIn(0, 255)
|
||||
}
|
||||
pathEffect = null
|
||||
}
|
||||
}
|
||||
|
||||
drawPath(path, filledPaint)
|
||||
}
|
||||
drawPath(path, pathPaint)
|
||||
} else {
|
||||
drawPath(path, pathPaint)
|
||||
}
|
||||
}
|
||||
}
|
||||
+310
@@ -0,0 +1,310 @@
|
||||
/*
|
||||
* 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.draw.presentation.components.controls
|
||||
|
||||
import androidx.compose.animation.AnimatedVisibility
|
||||
import androidx.compose.animation.expandVertically
|
||||
import androidx.compose.animation.fadeIn
|
||||
import androidx.compose.animation.fadeOut
|
||||
import androidx.compose.animation.shrinkVertically
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.navigationBarsPadding
|
||||
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.graphics.Color
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.t8rin.imagetoolbox.core.domain.model.Pt
|
||||
import com.t8rin.imagetoolbox.core.domain.model.pt
|
||||
import com.t8rin.imagetoolbox.core.resources.Icons
|
||||
import com.t8rin.imagetoolbox.core.resources.R
|
||||
import com.t8rin.imagetoolbox.core.resources.icons.BackgroundColor
|
||||
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.ColorRowSelector
|
||||
import com.t8rin.imagetoolbox.core.ui.widget.controls.selection.HelperGridParamsSelector
|
||||
import com.t8rin.imagetoolbox.core.ui.widget.controls.selection.ImageFormatSelector
|
||||
import com.t8rin.imagetoolbox.core.ui.widget.controls.selection.MagnifierEnabledSelector
|
||||
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.draw.domain.DrawBehavior
|
||||
import com.t8rin.imagetoolbox.feature.draw.domain.DrawLineStyle
|
||||
import com.t8rin.imagetoolbox.feature.draw.domain.DrawMode
|
||||
import com.t8rin.imagetoolbox.feature.draw.domain.DrawPathMode
|
||||
import com.t8rin.imagetoolbox.feature.draw.presentation.components.BrushSoftnessSelector
|
||||
import com.t8rin.imagetoolbox.feature.draw.presentation.components.DrawColorSelector
|
||||
import com.t8rin.imagetoolbox.feature.draw.presentation.components.DrawLineAngleSelector
|
||||
import com.t8rin.imagetoolbox.feature.draw.presentation.components.DrawLineStyleSelector
|
||||
import com.t8rin.imagetoolbox.feature.draw.presentation.components.DrawModeSelector
|
||||
import com.t8rin.imagetoolbox.feature.draw.presentation.components.DrawPathModeSelector
|
||||
import com.t8rin.imagetoolbox.feature.draw.presentation.components.LineWidthSelector
|
||||
import com.t8rin.imagetoolbox.feature.draw.presentation.components.OpenColorPickerCard
|
||||
import com.t8rin.imagetoolbox.feature.draw.presentation.components.canShowLineAngle
|
||||
import com.t8rin.imagetoolbox.feature.draw.presentation.screenLogic.DrawComponent
|
||||
import com.t8rin.imagetoolbox.feature.pick_color.presentation.components.PickColorFromImageSheet
|
||||
|
||||
@Composable
|
||||
internal fun DrawContentControls(
|
||||
component: DrawComponent,
|
||||
secondaryControls: @Composable () -> Unit,
|
||||
drawColor: Color,
|
||||
onDrawColorChange: (Color) -> Unit,
|
||||
strokeWidth: Pt,
|
||||
onStrokeWidthChange: (Pt) -> Unit,
|
||||
brushSoftness: Pt,
|
||||
onBrushSoftnessChange: (Pt) -> Unit,
|
||||
alpha: Float,
|
||||
onAlphaChange: (Float) -> Unit,
|
||||
showLineAngle: Boolean,
|
||||
onShowLineAngleChange: (Boolean) -> Unit
|
||||
) {
|
||||
var showPickColorSheet by rememberSaveable { mutableStateOf(false) }
|
||||
|
||||
val isPortrait by isPortraitOrientationAsState()
|
||||
|
||||
val drawMode = component.drawMode
|
||||
val drawPathMode = component.drawPathMode
|
||||
val drawLineStyle = component.drawLineStyle
|
||||
|
||||
Column(
|
||||
modifier = Modifier.padding(16.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally
|
||||
) {
|
||||
if (!isPortrait) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.padding(vertical = 8.dp)
|
||||
.container(shape = ShapeDefaults.circle)
|
||||
) {
|
||||
secondaryControls()
|
||||
}
|
||||
}
|
||||
AnimatedVisibility(
|
||||
visible = drawMode !is DrawMode.SpotHeal && drawMode !is DrawMode.Warp,
|
||||
enter = fadeIn() + expandVertically(),
|
||||
exit = fadeOut() + shrinkVertically(),
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
) {
|
||||
OpenColorPickerCard(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
onOpen = {
|
||||
component.openColorPicker()
|
||||
showPickColorSheet = true
|
||||
}
|
||||
)
|
||||
}
|
||||
AnimatedVisibility(
|
||||
visible = drawMode !is DrawMode.PathEffect && drawMode !is DrawMode.Image && drawMode !is DrawMode.SpotHeal && drawMode !is DrawMode.Warp,
|
||||
enter = fadeIn() + expandVertically(),
|
||||
exit = fadeOut() + shrinkVertically(),
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
) {
|
||||
DrawColorSelector(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
value = drawColor,
|
||||
onValueChange = onDrawColorChange
|
||||
)
|
||||
}
|
||||
AnimatedVisibility(
|
||||
visible = drawPathMode.canChangeStrokeWidth,
|
||||
enter = fadeIn() + expandVertically(),
|
||||
exit = fadeOut() + shrinkVertically(),
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
) {
|
||||
LineWidthSelector(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
title = if (drawMode is DrawMode.Text) {
|
||||
stringResource(R.string.font_size)
|
||||
} else stringResource(R.string.line_width),
|
||||
valueRange = if (drawMode is DrawMode.Image) {
|
||||
10f..120f
|
||||
} else 1f..100f,
|
||||
value = strokeWidth.value,
|
||||
onValueChange = { onStrokeWidthChange(it.pt) }
|
||||
)
|
||||
}
|
||||
AnimatedVisibility(
|
||||
visible = drawMode !is DrawMode.Highlighter && drawMode !is DrawMode.PathEffect && drawMode !is DrawMode.SpotHeal && drawMode !is DrawMode.Warp,
|
||||
enter = fadeIn() + expandVertically(),
|
||||
exit = fadeOut() + shrinkVertically(),
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
) {
|
||||
BrushSoftnessSelector(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
value = brushSoftness.value,
|
||||
onValueChange = { onBrushSoftnessChange(it.pt) }
|
||||
)
|
||||
}
|
||||
if (component.drawBehavior is DrawBehavior.Background) {
|
||||
ColorRowSelector(
|
||||
value = component.backgroundColor,
|
||||
onValueChange = component::updateBackgroundColor,
|
||||
icon = Icons.Outlined.BackgroundColor,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.container(
|
||||
shape = ShapeDefaults.extraLarge
|
||||
)
|
||||
)
|
||||
}
|
||||
AnimatedVisibility(
|
||||
visible = drawMode !is DrawMode.Neon && drawMode !is DrawMode.PathEffect && drawMode !is DrawMode.SpotHeal && drawMode !is DrawMode.Warp,
|
||||
enter = fadeIn() + expandVertically(),
|
||||
exit = fadeOut() + shrinkVertically(),
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
) {
|
||||
AlphaSelector(
|
||||
value = alpha,
|
||||
onValueChange = onAlphaChange,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
)
|
||||
}
|
||||
DrawModeSelector(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
value = drawMode,
|
||||
strokeWidth = strokeWidth,
|
||||
onValueChange = component::updateDrawMode,
|
||||
values = remember(drawLineStyle) {
|
||||
derivedStateOf {
|
||||
if (drawLineStyle == DrawLineStyle.None) {
|
||||
DrawMode.entries
|
||||
} else {
|
||||
listOf(
|
||||
DrawMode.Pen,
|
||||
DrawMode.Highlighter,
|
||||
DrawMode.Neon
|
||||
)
|
||||
}
|
||||
}
|
||||
}.value,
|
||||
addFiltersSheetComponent = component.addFiltersSheetComponent,
|
||||
filterTemplateCreationSheetComponent = component.filterTemplateCreationSheetComponent
|
||||
)
|
||||
AnimatedVisibility(
|
||||
visible = drawMode !is DrawMode.Warp,
|
||||
enter = fadeIn() + expandVertically(),
|
||||
exit = fadeOut() + shrinkVertically(),
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
) {
|
||||
DrawPathModeSelector(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
value = drawPathMode,
|
||||
onValueChange = component::updateDrawPathMode,
|
||||
values = remember(drawMode, drawLineStyle) {
|
||||
derivedStateOf {
|
||||
if (drawMode !is DrawMode.Text && drawMode !is DrawMode.Image) {
|
||||
when (drawLineStyle) {
|
||||
DrawLineStyle.None -> DrawPathMode.entries
|
||||
|
||||
!is DrawLineStyle.Stamped<*> -> listOf(
|
||||
DrawPathMode.Free,
|
||||
DrawPathMode.Line,
|
||||
DrawPathMode.LinePointingArrow(),
|
||||
DrawPathMode.PointingArrow(),
|
||||
DrawPathMode.DoublePointingArrow(),
|
||||
DrawPathMode.DoubleLinePointingArrow(),
|
||||
) + DrawPathMode.outlinedEntries
|
||||
|
||||
else -> listOf(
|
||||
DrawPathMode.Free,
|
||||
DrawPathMode.Line
|
||||
) + DrawPathMode.outlinedEntries
|
||||
}
|
||||
} else {
|
||||
listOf(
|
||||
DrawPathMode.Free,
|
||||
DrawPathMode.Line
|
||||
) + DrawPathMode.outlinedEntries
|
||||
}
|
||||
}
|
||||
}.value,
|
||||
drawMode = drawMode
|
||||
)
|
||||
}
|
||||
AnimatedVisibility(
|
||||
visible = drawPathMode.canShowLineAngle() && drawMode !is DrawMode.Warp,
|
||||
enter = fadeIn() + expandVertically(),
|
||||
exit = fadeOut() + shrinkVertically(),
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
) {
|
||||
DrawLineAngleSelector(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
checked = showLineAngle,
|
||||
onCheckedChange = onShowLineAngleChange
|
||||
)
|
||||
}
|
||||
AnimatedVisibility(
|
||||
visible = drawMode !is DrawMode.Warp,
|
||||
enter = fadeIn() + expandVertically(),
|
||||
exit = fadeOut() + shrinkVertically(),
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
) {
|
||||
DrawLineStyleSelector(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
value = drawLineStyle,
|
||||
onValueChange = component::updateDrawLineStyle
|
||||
)
|
||||
}
|
||||
HelperGridParamsSelector(
|
||||
value = component.helperGridParams,
|
||||
onValueChange = component::updateHelperGridParams,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
)
|
||||
MagnifierEnabledSelector(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
shape = ShapeDefaults.extraLarge,
|
||||
)
|
||||
SaveExifWidget(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
checked = component.saveExif,
|
||||
imageFormat = component.imageFormat,
|
||||
onCheckedChange = component::setSaveExif
|
||||
)
|
||||
ImageFormatSelector(
|
||||
modifier = Modifier.navigationBarsPadding(),
|
||||
forceEnabled = component.drawBehavior is DrawBehavior.Background,
|
||||
value = component.imageFormat,
|
||||
onValueChange = component::setImageFormat
|
||||
)
|
||||
}
|
||||
|
||||
var colorPickerColor by rememberSaveable(stateSaver = ColorSaver) { mutableStateOf(Color.Black) }
|
||||
PickColorFromImageSheet(
|
||||
visible = showPickColorSheet,
|
||||
onDismiss = {
|
||||
showPickColorSheet = false
|
||||
},
|
||||
bitmap = component.colorPickerBitmap,
|
||||
onColorChange = { colorPickerColor = it },
|
||||
color = colorPickerColor
|
||||
)
|
||||
}
|
||||
+246
@@ -0,0 +1,246 @@
|
||||
/*
|
||||
* 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.draw.presentation.components.controls
|
||||
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.WindowInsets
|
||||
import androidx.compose.foundation.layout.asPaddingValues
|
||||
import androidx.compose.foundation.layout.calculateEndPadding
|
||||
import androidx.compose.foundation.layout.calculateStartPadding
|
||||
import androidx.compose.foundation.layout.displayCutout
|
||||
import androidx.compose.foundation.layout.fillMaxHeight
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.navigationBars
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.lazy.staggeredgrid.LazyVerticalStaggeredGrid
|
||||
import androidx.compose.foundation.lazy.staggeredgrid.StaggeredGridCells
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.text.KeyboardOptions
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableIntStateOf
|
||||
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.graphics.Color
|
||||
import androidx.compose.ui.platform.LocalLayoutDirection
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.input.KeyboardType
|
||||
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.BackgroundColor
|
||||
import com.t8rin.imagetoolbox.core.resources.icons.ImagesMode
|
||||
import com.t8rin.imagetoolbox.core.resources.icons.ImagesearchRoller
|
||||
import com.t8rin.imagetoolbox.core.ui.theme.toColor
|
||||
import com.t8rin.imagetoolbox.core.ui.utils.helper.ImageUtils.restrict
|
||||
import com.t8rin.imagetoolbox.core.ui.utils.provider.LocalScreenSize
|
||||
import com.t8rin.imagetoolbox.core.ui.widget.controls.selection.ColorRowSelector
|
||||
import com.t8rin.imagetoolbox.core.ui.widget.enhanced.EnhancedButton
|
||||
import com.t8rin.imagetoolbox.core.ui.widget.enhanced.EnhancedModalBottomSheet
|
||||
import com.t8rin.imagetoolbox.core.ui.widget.enhanced.enhancedFlingBehavior
|
||||
import com.t8rin.imagetoolbox.core.ui.widget.enhanced.enhancedVerticalScroll
|
||||
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.preferences.PreferenceItem
|
||||
import com.t8rin.imagetoolbox.core.ui.widget.saver.ColorSaver
|
||||
import com.t8rin.imagetoolbox.core.ui.widget.text.AutoSizeText
|
||||
import com.t8rin.imagetoolbox.core.ui.widget.text.RoundedTextField
|
||||
import com.t8rin.imagetoolbox.core.ui.widget.text.TitleItem
|
||||
import com.t8rin.imagetoolbox.feature.draw.presentation.screenLogic.DrawComponent
|
||||
|
||||
@Composable
|
||||
internal fun DrawContentNoDataControls(
|
||||
component: DrawComponent,
|
||||
onPickImage: () -> Unit
|
||||
) {
|
||||
var showBackgroundDrawingSetup by rememberSaveable { mutableStateOf(false) }
|
||||
|
||||
val cutout = WindowInsets.displayCutout.asPaddingValues()
|
||||
LazyVerticalStaggeredGrid(
|
||||
modifier = Modifier.fillMaxHeight(),
|
||||
columns = StaggeredGridCells.Adaptive(300.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(
|
||||
space = 12.dp,
|
||||
alignment = Alignment.CenterHorizontally
|
||||
),
|
||||
verticalItemSpacing = 12.dp,
|
||||
contentPadding = PaddingValues(
|
||||
bottom = 12.dp + WindowInsets
|
||||
.navigationBars
|
||||
.asPaddingValues()
|
||||
.calculateBottomPadding(),
|
||||
top = 12.dp,
|
||||
end = 12.dp + cutout.calculateEndPadding(
|
||||
LocalLayoutDirection.current
|
||||
),
|
||||
start = 12.dp + cutout.calculateStartPadding(
|
||||
LocalLayoutDirection.current
|
||||
)
|
||||
),
|
||||
flingBehavior = enhancedFlingBehavior()
|
||||
) {
|
||||
item {
|
||||
PreferenceItem(
|
||||
onClick = onPickImage,
|
||||
startIcon = Icons.Outlined.ImagesMode,
|
||||
title = stringResource(R.string.draw_on_image),
|
||||
subtitle = stringResource(R.string.draw_on_image_sub),
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
)
|
||||
}
|
||||
item {
|
||||
PreferenceItem(
|
||||
onClick = { showBackgroundDrawingSetup = true },
|
||||
startIcon = Icons.Outlined.ImagesearchRoller,
|
||||
title = stringResource(R.string.draw_on_background),
|
||||
subtitle = stringResource(R.string.draw_on_background_sub),
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
val drawOnBackgroundParams = component.drawOnBackgroundParams
|
||||
val screenSize = LocalScreenSize.current
|
||||
val screenWidth = screenSize.widthPx
|
||||
val screenHeight = screenSize.heightPx
|
||||
|
||||
var width by remember(
|
||||
showBackgroundDrawingSetup,
|
||||
screenWidth,
|
||||
drawOnBackgroundParams
|
||||
) {
|
||||
mutableIntStateOf(drawOnBackgroundParams.width.takeIf { it > 0 } ?: screenWidth)
|
||||
}
|
||||
var height by remember(
|
||||
showBackgroundDrawingSetup,
|
||||
screenHeight,
|
||||
drawOnBackgroundParams
|
||||
) {
|
||||
mutableIntStateOf(drawOnBackgroundParams.height.takeIf { it > 0 } ?: screenHeight)
|
||||
}
|
||||
var sheetBackgroundColor by rememberSaveable(
|
||||
showBackgroundDrawingSetup,
|
||||
drawOnBackgroundParams,
|
||||
stateSaver = ColorSaver
|
||||
) {
|
||||
mutableStateOf(drawOnBackgroundParams.color?.toColor() ?: Color.White)
|
||||
}
|
||||
EnhancedModalBottomSheet(
|
||||
title = {
|
||||
TitleItem(
|
||||
text = stringResource(R.string.draw),
|
||||
icon = Icons.Rounded.ImagesearchRoller
|
||||
)
|
||||
},
|
||||
confirmButton = {
|
||||
EnhancedButton(
|
||||
containerColor = MaterialTheme.colorScheme.secondaryContainer,
|
||||
onClick = {
|
||||
showBackgroundDrawingSetup = false
|
||||
component.startDrawOnBackground(
|
||||
reqWidth = width,
|
||||
reqHeight = height,
|
||||
color = sheetBackgroundColor
|
||||
)
|
||||
}
|
||||
) {
|
||||
AutoSizeText(stringResource(R.string.ok))
|
||||
}
|
||||
},
|
||||
sheetContent = {
|
||||
Box {
|
||||
Column(Modifier.enhancedVerticalScroll(rememberScrollState())) {
|
||||
Row(
|
||||
Modifier
|
||||
.padding(16.dp)
|
||||
.container(shape = ShapeDefaults.extraLarge)
|
||||
) {
|
||||
RoundedTextField(
|
||||
value = width.takeIf { it != 0 }?.toString() ?: "",
|
||||
onValueChange = {
|
||||
width = 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
|
||||
)
|
||||
)
|
||||
RoundedTextField(
|
||||
value = height.takeIf { it != 0 }?.toString() ?: "",
|
||||
onValueChange = {
|
||||
height = 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
|
||||
),
|
||||
)
|
||||
}
|
||||
ColorRowSelector(
|
||||
value = sheetBackgroundColor,
|
||||
onValueChange = { sheetBackgroundColor = it },
|
||||
icon = Icons.Outlined.BackgroundColor,
|
||||
modifier = Modifier
|
||||
.padding(
|
||||
start = 16.dp,
|
||||
end = 16.dp,
|
||||
bottom = 16.dp
|
||||
)
|
||||
.container(ShapeDefaults.extraLarge)
|
||||
)
|
||||
}
|
||||
}
|
||||
},
|
||||
visible = showBackgroundDrawingSetup,
|
||||
onDismiss = {
|
||||
showBackgroundDrawingSetup = it
|
||||
}
|
||||
)
|
||||
}
|
||||
+65
@@ -0,0 +1,65 @@
|
||||
/*
|
||||
* 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.draw.presentation.components.controls
|
||||
|
||||
import com.t8rin.imagetoolbox.core.resources.Icons
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.runtime.Composable
|
||||
import com.t8rin.imagetoolbox.core.resources.icons.Redo
|
||||
import com.t8rin.imagetoolbox.core.resources.icons.Undo
|
||||
import com.t8rin.imagetoolbox.core.ui.widget.buttons.EraseModeButton
|
||||
import com.t8rin.imagetoolbox.core.ui.widget.buttons.PanModeButton
|
||||
import com.t8rin.imagetoolbox.core.ui.widget.enhanced.EnhancedIconButton
|
||||
import com.t8rin.imagetoolbox.feature.draw.presentation.screenLogic.DrawComponent
|
||||
|
||||
@Composable
|
||||
internal fun DrawContentSecondaryControls(
|
||||
component: DrawComponent,
|
||||
panEnabled: Boolean,
|
||||
onTogglePanEnabled: () -> Unit,
|
||||
isEraserOn: Boolean,
|
||||
onToggleIsEraserOn: () -> Unit
|
||||
) {
|
||||
PanModeButton(
|
||||
selected = panEnabled,
|
||||
onClick = onTogglePanEnabled
|
||||
)
|
||||
EnhancedIconButton(
|
||||
onClick = component::undo,
|
||||
enabled = component.lastPaths.isNotEmpty() || component.paths.isNotEmpty()
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Rounded.Undo,
|
||||
contentDescription = "Undo"
|
||||
)
|
||||
}
|
||||
EnhancedIconButton(
|
||||
onClick = component::redo,
|
||||
enabled = component.undonePaths.isNotEmpty()
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Rounded.Redo,
|
||||
contentDescription = "Redo"
|
||||
)
|
||||
}
|
||||
EraseModeButton(
|
||||
selected = isEraserOn,
|
||||
enabled = !panEnabled,
|
||||
onClick = onToggleIsEraserOn
|
||||
)
|
||||
}
|
||||
+96
@@ -0,0 +1,96 @@
|
||||
/*
|
||||
* 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.draw.presentation.components.element
|
||||
|
||||
import androidx.compose.animation.AnimatedVisibility
|
||||
import androidx.compose.animation.expandVertically
|
||||
import androidx.compose.animation.fadeIn
|
||||
import androidx.compose.animation.fadeOut
|
||||
import androidx.compose.animation.shrinkVertically
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.t8rin.imagetoolbox.core.domain.utils.roundTo
|
||||
import com.t8rin.imagetoolbox.core.resources.R
|
||||
import com.t8rin.imagetoolbox.core.ui.widget.enhanced.EnhancedSliderItem
|
||||
import com.t8rin.imagetoolbox.core.ui.widget.modifier.ShapeDefaults
|
||||
import com.t8rin.imagetoolbox.feature.draw.domain.DrawPathMode
|
||||
import com.t8rin.imagetoolbox.feature.draw.presentation.components.utils.angle
|
||||
import com.t8rin.imagetoolbox.feature.draw.presentation.components.utils.isArrow
|
||||
import com.t8rin.imagetoolbox.feature.draw.presentation.components.utils.sizeScale
|
||||
import com.t8rin.imagetoolbox.feature.draw.presentation.components.utils.updateArrow
|
||||
|
||||
@Composable
|
||||
internal fun ArrowParamsSelector(
|
||||
value: DrawPathMode,
|
||||
onValueChange: (DrawPathMode) -> Unit
|
||||
) {
|
||||
AnimatedVisibility(
|
||||
visible = value.isArrow(),
|
||||
enter = fadeIn() + expandVertically(),
|
||||
exit = fadeOut() + shrinkVertically()
|
||||
) {
|
||||
Column {
|
||||
EnhancedSliderItem(
|
||||
value = value.sizeScale(),
|
||||
title = stringResource(R.string.head_length_scale),
|
||||
valueRange = 0.5f..8f,
|
||||
internalStateTransformation = {
|
||||
it.roundTo(1)
|
||||
},
|
||||
onValueChange = {
|
||||
onValueChange(
|
||||
value.updateArrow(sizeScale = it)
|
||||
)
|
||||
},
|
||||
containerColor = MaterialTheme.colorScheme.surface,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 8.dp),
|
||||
shape = ShapeDefaults.top
|
||||
)
|
||||
Spacer(modifier = Modifier.height(4.dp))
|
||||
EnhancedSliderItem(
|
||||
value = value.angle() - 90f,
|
||||
title = stringResource(R.string.angle),
|
||||
valueRange = 0f..90f,
|
||||
internalStateTransformation = {
|
||||
it.roundTo(1)
|
||||
},
|
||||
onValueChange = {
|
||||
onValueChange(
|
||||
value.updateArrow(angle = it + 90f)
|
||||
)
|
||||
},
|
||||
containerColor = MaterialTheme.colorScheme.surface,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 8.dp),
|
||||
shape = ShapeDefaults.bottom
|
||||
)
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
}
|
||||
}
|
||||
}
|
||||
+158
@@ -0,0 +1,158 @@
|
||||
/*
|
||||
* 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.draw.presentation.components.element
|
||||
|
||||
import androidx.compose.animation.AnimatedContent
|
||||
import androidx.compose.animation.AnimatedVisibility
|
||||
import androidx.compose.animation.expandVertically
|
||||
import androidx.compose.animation.fadeIn
|
||||
import androidx.compose.animation.fadeOut
|
||||
import androidx.compose.animation.shrinkVertically
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
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.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 com.t8rin.imagetoolbox.core.filters.presentation.model.toUiFilter
|
||||
import com.t8rin.imagetoolbox.core.filters.presentation.widget.AddFilterButton
|
||||
import com.t8rin.imagetoolbox.core.filters.presentation.widget.FilterItem
|
||||
import com.t8rin.imagetoolbox.core.filters.presentation.widget.FilterTemplateCreationSheetComponent
|
||||
import com.t8rin.imagetoolbox.core.filters.presentation.widget.addFilters.AddFiltersSheet
|
||||
import com.t8rin.imagetoolbox.core.filters.presentation.widget.addFilters.AddFiltersSheetComponent
|
||||
import com.t8rin.imagetoolbox.core.resources.Icons
|
||||
import com.t8rin.imagetoolbox.core.resources.R
|
||||
import com.t8rin.imagetoolbox.core.resources.icons.AutoFixHigh
|
||||
import com.t8rin.imagetoolbox.core.ui.theme.mixedContainer
|
||||
import com.t8rin.imagetoolbox.core.ui.widget.enhanced.EnhancedButton
|
||||
import com.t8rin.imagetoolbox.core.ui.widget.modifier.ShapeDefaults
|
||||
import com.t8rin.imagetoolbox.core.ui.widget.other.InfoContainer
|
||||
import com.t8rin.imagetoolbox.feature.draw.domain.DrawMode
|
||||
|
||||
@Composable
|
||||
internal fun CustomPathEffectParamsSelector(
|
||||
value: DrawMode,
|
||||
onValueChange: (DrawMode) -> Unit,
|
||||
addFiltersSheetComponent: AddFiltersSheetComponent,
|
||||
filterTemplateCreationSheetComponent: FilterTemplateCreationSheetComponent
|
||||
) {
|
||||
AnimatedVisibility(
|
||||
visible = value is DrawMode.PathEffect.Custom,
|
||||
enter = fadeIn() + expandVertically(),
|
||||
exit = fadeOut() + shrinkVertically()
|
||||
) {
|
||||
val filter by remember(value) {
|
||||
derivedStateOf {
|
||||
(value as? DrawMode.PathEffect.Custom)?.filter?.toUiFilter()
|
||||
}
|
||||
}
|
||||
var showFilterSelection by rememberSaveable {
|
||||
mutableStateOf(false)
|
||||
}
|
||||
AnimatedContent(
|
||||
targetState = filter,
|
||||
contentKey = { it != null }
|
||||
) { filter ->
|
||||
Column(
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
modifier = Modifier.padding(horizontal = 8.dp)
|
||||
) {
|
||||
if (filter != null) {
|
||||
FilterItem(
|
||||
filter = filter,
|
||||
showDragHandle = false,
|
||||
onRemove = {
|
||||
onValueChange(
|
||||
DrawMode.PathEffect.Custom()
|
||||
)
|
||||
},
|
||||
onFilterChange = { value ->
|
||||
onValueChange(
|
||||
DrawMode.PathEffect.Custom(filter.copy(value))
|
||||
)
|
||||
},
|
||||
backgroundColor = MaterialTheme.colorScheme.surface,
|
||||
shape = ShapeDefaults.default,
|
||||
canHide = false,
|
||||
onCreateTemplate = null
|
||||
)
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
EnhancedButton(
|
||||
containerColor = MaterialTheme.colorScheme.mixedContainer,
|
||||
onClick = {
|
||||
showFilterSelection = true
|
||||
}
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Rounded.AutoFixHigh,
|
||||
contentDescription = stringResource(R.string.replace_filter)
|
||||
)
|
||||
Spacer(Modifier.width(8.dp))
|
||||
Text(stringResource(id = R.string.replace_filter))
|
||||
}
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
} else {
|
||||
InfoContainer(
|
||||
containerColor = MaterialTheme.colorScheme.surfaceContainerLow,
|
||||
text = stringResource(R.string.pick_filter_info)
|
||||
)
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
AddFilterButton(
|
||||
onClick = {
|
||||
showFilterSelection = true
|
||||
}
|
||||
)
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
}
|
||||
}
|
||||
AddFiltersSheet(
|
||||
component = addFiltersSheetComponent,
|
||||
filterTemplateCreationSheetComponent = filterTemplateCreationSheetComponent,
|
||||
visible = showFilterSelection,
|
||||
onVisibleChange = {
|
||||
showFilterSelection = it
|
||||
},
|
||||
canAddTemplates = false,
|
||||
previewBitmap = null,
|
||||
onFilterPicked = {
|
||||
onValueChange(
|
||||
DrawMode.PathEffect.Custom(it.newInstance())
|
||||
)
|
||||
},
|
||||
onFilterPickedWithParams = {
|
||||
onValueChange(
|
||||
DrawMode.PathEffect.Custom(it)
|
||||
)
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
+102
@@ -0,0 +1,102 @@
|
||||
/*
|
||||
* 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.draw.presentation.components.element
|
||||
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
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.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import com.t8rin.imagetoolbox.core.resources.R
|
||||
import com.t8rin.imagetoolbox.core.ui.widget.enhanced.EnhancedButton
|
||||
import com.t8rin.imagetoolbox.core.ui.widget.enhanced.EnhancedModalBottomSheet
|
||||
import com.t8rin.imagetoolbox.core.ui.widget.enhanced.enhancedVerticalScroll
|
||||
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.AutoSizeText
|
||||
import com.t8rin.imagetoolbox.core.ui.widget.text.TitleItem
|
||||
import com.t8rin.imagetoolbox.feature.draw.domain.DrawPathMode
|
||||
import com.t8rin.imagetoolbox.feature.draw.presentation.components.utils.getIcon
|
||||
import com.t8rin.imagetoolbox.feature.draw.presentation.components.utils.getSubtitle
|
||||
import com.t8rin.imagetoolbox.feature.draw.presentation.components.utils.getTitle
|
||||
|
||||
@Composable
|
||||
internal fun DrawPathModeInfoSheet(
|
||||
visible: Boolean,
|
||||
onDismiss: () -> Unit,
|
||||
values: List<DrawPathMode>
|
||||
) {
|
||||
EnhancedModalBottomSheet(
|
||||
sheetContent = {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.enhancedVerticalScroll(rememberScrollState())
|
||||
.padding(8.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(4.dp)
|
||||
) {
|
||||
values.forEachIndexed { index, item ->
|
||||
Column(
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.container(
|
||||
shape = ShapeDefaults.byIndex(
|
||||
index = index,
|
||||
size = values.size
|
||||
),
|
||||
resultPadding = 0.dp
|
||||
)
|
||||
) {
|
||||
TitleItem(text = stringResource(item.getTitle()), icon = item.getIcon())
|
||||
Text(
|
||||
text = stringResource(item.getSubtitle()),
|
||||
modifier = Modifier.padding(
|
||||
start = 16.dp,
|
||||
end = 16.dp,
|
||||
bottom = 16.dp
|
||||
),
|
||||
fontSize = 14.sp,
|
||||
lineHeight = 18.sp
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
visible = visible,
|
||||
onDismiss = {
|
||||
if (!it) onDismiss()
|
||||
},
|
||||
title = {
|
||||
TitleItem(text = stringResource(R.string.draw_path_mode))
|
||||
},
|
||||
confirmButton = {
|
||||
EnhancedButton(
|
||||
containerColor = MaterialTheme.colorScheme.secondaryContainer,
|
||||
onClick = onDismiss
|
||||
) {
|
||||
AutoSizeText(stringResource(R.string.close))
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
+77
@@ -0,0 +1,77 @@
|
||||
/*
|
||||
* 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.draw.presentation.components.element
|
||||
|
||||
import androidx.compose.animation.AnimatedVisibility
|
||||
import androidx.compose.animation.expandVertically
|
||||
import androidx.compose.animation.fadeIn
|
||||
import androidx.compose.animation.fadeOut
|
||||
import androidx.compose.animation.shrinkVertically
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.unit.dp
|
||||
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.core.ui.widget.modifier.ShapeDefaults
|
||||
import com.t8rin.imagetoolbox.feature.draw.domain.DrawPathMode
|
||||
import com.t8rin.imagetoolbox.feature.draw.presentation.components.utils.isFloodFill
|
||||
import com.t8rin.imagetoolbox.feature.draw.presentation.components.utils.tolerance
|
||||
import com.t8rin.imagetoolbox.feature.draw.presentation.components.utils.updateFloodFill
|
||||
|
||||
|
||||
@Composable
|
||||
internal fun FloodFillParamsSelector(
|
||||
value: DrawPathMode,
|
||||
onValueChange: (DrawPathMode) -> Unit
|
||||
) {
|
||||
AnimatedVisibility(
|
||||
visible = value.isFloodFill(),
|
||||
enter = fadeIn() + expandVertically(),
|
||||
exit = fadeOut() + shrinkVertically()
|
||||
) {
|
||||
Column {
|
||||
EnhancedSliderItem(
|
||||
value = value.tolerance(),
|
||||
title = stringResource(R.string.tolerance),
|
||||
valueRange = 0f..1f,
|
||||
internalStateTransformation = {
|
||||
it.roundToTwoDigits()
|
||||
},
|
||||
onValueChange = {
|
||||
onValueChange(
|
||||
value.updateFloodFill(tolerance = it.roundToTwoDigits())
|
||||
)
|
||||
},
|
||||
containerColor = MaterialTheme.colorScheme.surface,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 8.dp),
|
||||
shape = ShapeDefaults.default
|
||||
)
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
}
|
||||
}
|
||||
}
|
||||
+111
@@ -0,0 +1,111 @@
|
||||
/*
|
||||
* 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.draw.presentation.components.element
|
||||
|
||||
import androidx.compose.animation.AnimatedVisibility
|
||||
import androidx.compose.animation.expandVertically
|
||||
import androidx.compose.animation.fadeIn
|
||||
import androidx.compose.animation.fadeOut
|
||||
import androidx.compose.animation.shrinkVertically
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.t8rin.colors.util.roundToTwoDigits
|
||||
import com.t8rin.imagetoolbox.core.domain.model.Pt
|
||||
import com.t8rin.imagetoolbox.core.domain.model.coerceIn
|
||||
import com.t8rin.imagetoolbox.core.domain.model.pt
|
||||
import com.t8rin.imagetoolbox.core.resources.R
|
||||
import com.t8rin.imagetoolbox.core.ui.widget.controls.selection.ImageSelector
|
||||
import com.t8rin.imagetoolbox.core.ui.widget.enhanced.EnhancedSliderItem
|
||||
import com.t8rin.imagetoolbox.core.ui.widget.modifier.ShapeDefaults
|
||||
import com.t8rin.imagetoolbox.feature.draw.domain.DrawMode
|
||||
|
||||
@Composable
|
||||
internal fun ImageParamsSelector(
|
||||
value: DrawMode,
|
||||
onValueChange: (DrawMode) -> Unit,
|
||||
strokeWidth: Pt
|
||||
) {
|
||||
AnimatedVisibility(
|
||||
visible = value is DrawMode.Image,
|
||||
enter = fadeIn() + expandVertically(),
|
||||
exit = fadeOut() + shrinkVertically()
|
||||
) {
|
||||
Column {
|
||||
ImageSelector(
|
||||
modifier = Modifier
|
||||
.padding(horizontal = 8.dp),
|
||||
value = (value as? DrawMode.Image)?.imageData ?: "",
|
||||
onValueChange = {
|
||||
onValueChange(
|
||||
(value as? DrawMode.Image)?.copy(
|
||||
imageData = it
|
||||
) ?: value
|
||||
)
|
||||
},
|
||||
subtitle = stringResource(id = R.string.draw_image_sub),
|
||||
shape = ShapeDefaults.top,
|
||||
color = MaterialTheme.colorScheme.surface
|
||||
)
|
||||
Spacer(modifier = Modifier.height(4.dp))
|
||||
val dashMinimum = -((strokeWidth.value * 0.9f) / 2).toInt().toFloat()
|
||||
LaunchedEffect(dashMinimum, value) {
|
||||
if (value is DrawMode.Image && value.repeatingInterval < dashMinimum.pt) {
|
||||
onValueChange(
|
||||
value.copy(
|
||||
repeatingInterval = value.repeatingInterval.coerceIn(
|
||||
dashMinimum.pt,
|
||||
100.pt
|
||||
)
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
EnhancedSliderItem(
|
||||
value = (value as? DrawMode.Image)?.repeatingInterval?.value ?: 0f,
|
||||
title = stringResource(R.string.dash_size),
|
||||
valueRange = dashMinimum..100f,
|
||||
internalStateTransformation = {
|
||||
it.roundToTwoDigits()
|
||||
},
|
||||
onValueChange = {
|
||||
onValueChange(
|
||||
(value as? DrawMode.Image)?.copy(
|
||||
repeatingInterval = it.pt.coerceIn(dashMinimum.pt, 100.pt)
|
||||
) ?: value
|
||||
)
|
||||
},
|
||||
containerColor = MaterialTheme.colorScheme.surface,
|
||||
valueSuffix = " Pt",
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 8.dp),
|
||||
shape = ShapeDefaults.bottom
|
||||
)
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
}
|
||||
}
|
||||
}
|
||||
+143
@@ -0,0 +1,143 @@
|
||||
/*
|
||||
* 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.draw.presentation.components.element
|
||||
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.defaultMinSize
|
||||
import androidx.compose.foundation.layout.offset
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
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.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.geometry.Offset
|
||||
import androidx.compose.ui.layout.onSizeChanged
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.IntOffset
|
||||
import androidx.compose.ui.unit.IntSize
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.t8rin.imagetoolbox.core.resources.Icons
|
||||
import com.t8rin.imagetoolbox.core.resources.icons.RotateRight
|
||||
import com.t8rin.imagetoolbox.core.ui.widget.modifier.ShapeDefaults
|
||||
import kotlin.math.atan2
|
||||
import kotlin.math.roundToInt
|
||||
|
||||
@Composable
|
||||
internal fun LineAngleIndicator(
|
||||
drawDownPosition: Offset,
|
||||
currentDrawPosition: Offset,
|
||||
imageWidth: Int,
|
||||
imageHeight: Int,
|
||||
isMagnifierEnabled: Boolean,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
val density = LocalDensity.current
|
||||
var size by remember {
|
||||
mutableStateOf(
|
||||
with(density) {
|
||||
IntSize(
|
||||
width = 96.dp.roundToPx(),
|
||||
height = 40.dp.roundToPx()
|
||||
)
|
||||
}
|
||||
)
|
||||
}
|
||||
val (indicatorWidth, indicatorHeight) = size
|
||||
|
||||
val offset by remember(
|
||||
density,
|
||||
indicatorWidth,
|
||||
indicatorHeight,
|
||||
currentDrawPosition,
|
||||
imageWidth
|
||||
) {
|
||||
derivedStateOf {
|
||||
with(density) {
|
||||
val verticalGap = 108.dp.roundToPx()
|
||||
val horizontalGap = -indicatorWidth / 2
|
||||
val verticalGapSafe = if (isMagnifierEnabled) {
|
||||
verticalGap - indicatorHeight
|
||||
} else {
|
||||
-verticalGap
|
||||
}
|
||||
|
||||
IntOffset(
|
||||
x = (currentDrawPosition.x.roundToInt() + horizontalGap)
|
||||
.coerceIn(0, (imageWidth - indicatorWidth).coerceAtLeast(0)),
|
||||
y = (currentDrawPosition.y.roundToInt() + verticalGapSafe)
|
||||
.coerceIn(0, (imageHeight - indicatorHeight).coerceAtLeast(0))
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Surface(
|
||||
modifier = modifier
|
||||
.offset { offset }
|
||||
.defaultMinSize(minHeight = 40.dp)
|
||||
.onSizeChanged { size = it },
|
||||
shape = ShapeDefaults.extraLarge,
|
||||
color = MaterialTheme.colorScheme.primaryContainer,
|
||||
contentColor = MaterialTheme.colorScheme.onPrimaryContainer,
|
||||
tonalElevation = 6.dp,
|
||||
shadowElevation = 2.dp
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.padding(horizontal = 12.dp, vertical = 8.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Rounded.RotateRight,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.padding(end = 6.dp)
|
||||
)
|
||||
val degrees by remember(drawDownPosition, currentDrawPosition) {
|
||||
derivedStateOf {
|
||||
drawDownPosition.angleDegreesTo(currentDrawPosition)
|
||||
}
|
||||
}
|
||||
Text(
|
||||
text = "${degrees}°",
|
||||
style = MaterialTheme.typography.labelLarge,
|
||||
fontWeight = FontWeight.SemiBold
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun Offset.angleDegreesTo(other: Offset): Int {
|
||||
val degrees = Math.toDegrees(
|
||||
atan2(
|
||||
y = (other.y - y).toDouble(),
|
||||
x = (other.x - x).toDouble()
|
||||
)
|
||||
).roundToInt()
|
||||
|
||||
return (degrees % FULL_CIRCLE_DEGREES + FULL_CIRCLE_DEGREES) % FULL_CIRCLE_DEGREES
|
||||
}
|
||||
|
||||
private const val FULL_CIRCLE_DEGREES = 360
|
||||
+55
@@ -0,0 +1,55 @@
|
||||
/*
|
||||
* 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.draw.presentation.components.element
|
||||
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import com.t8rin.imagetoolbox.core.resources.Icons
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.Shape
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import com.t8rin.imagetoolbox.core.resources.R
|
||||
import com.t8rin.imagetoolbox.core.resources.icons.FormatColorFill
|
||||
import com.t8rin.imagetoolbox.core.ui.widget.controls.selection.ColorRowSelector
|
||||
import com.t8rin.imagetoolbox.core.ui.widget.modifier.ShapeDefaults
|
||||
import com.t8rin.imagetoolbox.core.ui.widget.modifier.container
|
||||
|
||||
@Composable
|
||||
fun OutlinedFillColorSelector(
|
||||
value: Color?,
|
||||
onValueChange: (Color?) -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
shape: Shape = ShapeDefaults.default,
|
||||
containerColor: Color = Color.Unspecified
|
||||
) {
|
||||
ColorRowSelector(
|
||||
value = value,
|
||||
onValueChange = onValueChange,
|
||||
onNullClick = { onValueChange(null) },
|
||||
title = stringResource(R.string.fill_color),
|
||||
icon = Icons.Rounded.FormatColorFill,
|
||||
allowAlpha = true,
|
||||
modifier = modifier
|
||||
.fillMaxWidth()
|
||||
.container(
|
||||
color = containerColor,
|
||||
shape = shape
|
||||
)
|
||||
)
|
||||
}
|
||||
+65
@@ -0,0 +1,65 @@
|
||||
/*
|
||||
* 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.draw.presentation.components.element
|
||||
|
||||
import androidx.compose.animation.AnimatedVisibility
|
||||
import androidx.compose.animation.expandVertically
|
||||
import androidx.compose.animation.fadeIn
|
||||
import androidx.compose.animation.fadeOut
|
||||
import androidx.compose.animation.shrinkVertically
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.t8rin.imagetoolbox.core.ui.utils.helper.toColor
|
||||
import com.t8rin.imagetoolbox.core.ui.widget.modifier.ShapeDefaults
|
||||
import com.t8rin.imagetoolbox.feature.draw.domain.DrawPathMode
|
||||
import com.t8rin.imagetoolbox.feature.draw.presentation.components.utils.updateOutlined
|
||||
|
||||
@Composable
|
||||
internal fun OvalParamsSelector(
|
||||
value: DrawPathMode,
|
||||
onValueChange: (DrawPathMode) -> Unit,
|
||||
canChangeFillColor: Boolean
|
||||
) {
|
||||
AnimatedVisibility(
|
||||
visible = value is DrawPathMode.OutlinedOval && canChangeFillColor,
|
||||
enter = fadeIn() + expandVertically(),
|
||||
exit = fadeOut() + shrinkVertically()
|
||||
) {
|
||||
Column {
|
||||
OutlinedFillColorSelector(
|
||||
value = value.outlinedFillColor?.toColor(),
|
||||
onValueChange = {
|
||||
onValueChange(value.updateOutlined(it))
|
||||
},
|
||||
shape = ShapeDefaults.default,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 8.dp),
|
||||
containerColor = MaterialTheme.colorScheme.surface,
|
||||
)
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
}
|
||||
}
|
||||
}
|
||||
+52
@@ -0,0 +1,52 @@
|
||||
/*
|
||||
* 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.draw.presentation.components.element
|
||||
|
||||
import androidx.compose.animation.AnimatedVisibility
|
||||
import androidx.compose.animation.expandVertically
|
||||
import androidx.compose.animation.fadeIn
|
||||
import androidx.compose.animation.fadeOut
|
||||
import androidx.compose.animation.shrinkVertically
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.t8rin.imagetoolbox.feature.draw.domain.DrawMode
|
||||
import com.t8rin.imagetoolbox.feature.draw.presentation.components.PixelSizeSelector
|
||||
|
||||
@Composable
|
||||
internal fun PixelationParamsSelector(
|
||||
value: DrawMode,
|
||||
onValueChange: (DrawMode) -> Unit
|
||||
) {
|
||||
AnimatedVisibility(
|
||||
visible = value is DrawMode.PathEffect.Pixelation,
|
||||
enter = fadeIn() + expandVertically(),
|
||||
exit = fadeOut() + shrinkVertically()
|
||||
) {
|
||||
PixelSizeSelector(
|
||||
modifier = Modifier.padding(8.dp),
|
||||
value = (value as? DrawMode.PathEffect.Pixelation)?.pixelSize ?: 0f,
|
||||
onValueChange = {
|
||||
onValueChange(DrawMode.PathEffect.Pixelation(it))
|
||||
},
|
||||
color = MaterialTheme.colorScheme.surface
|
||||
)
|
||||
}
|
||||
}
|
||||
+139
@@ -0,0 +1,139 @@
|
||||
/*
|
||||
* 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.draw.presentation.components.element
|
||||
|
||||
import androidx.compose.animation.AnimatedVisibility
|
||||
import androidx.compose.animation.expandVertically
|
||||
import androidx.compose.animation.fadeIn
|
||||
import androidx.compose.animation.fadeOut
|
||||
import androidx.compose.animation.shrinkVertically
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
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.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.SquareFoot
|
||||
import com.t8rin.imagetoolbox.core.ui.utils.helper.toColor
|
||||
import com.t8rin.imagetoolbox.core.ui.widget.enhanced.EnhancedSliderItem
|
||||
import com.t8rin.imagetoolbox.core.ui.widget.modifier.ShapeDefaults
|
||||
import com.t8rin.imagetoolbox.core.ui.widget.preferences.PreferenceRowSwitch
|
||||
import com.t8rin.imagetoolbox.feature.draw.domain.DrawPathMode
|
||||
import com.t8rin.imagetoolbox.feature.draw.presentation.components.utils.isPolygon
|
||||
import com.t8rin.imagetoolbox.feature.draw.presentation.components.utils.isRegular
|
||||
import com.t8rin.imagetoolbox.feature.draw.presentation.components.utils.rotationDegrees
|
||||
import com.t8rin.imagetoolbox.feature.draw.presentation.components.utils.updateOutlined
|
||||
import com.t8rin.imagetoolbox.feature.draw.presentation.components.utils.updatePolygon
|
||||
import com.t8rin.imagetoolbox.feature.draw.presentation.components.utils.vertices
|
||||
import kotlin.math.roundToInt
|
||||
|
||||
@Composable
|
||||
internal fun PolygonParamsSelector(
|
||||
value: DrawPathMode,
|
||||
onValueChange: (DrawPathMode) -> Unit,
|
||||
canChangeFillColor: Boolean
|
||||
) {
|
||||
AnimatedVisibility(
|
||||
visible = value.isPolygon(),
|
||||
enter = fadeIn() + expandVertically(),
|
||||
exit = fadeOut() + shrinkVertically()
|
||||
) {
|
||||
Column {
|
||||
AnimatedVisibility(
|
||||
visible = canChangeFillColor,
|
||||
enter = fadeIn() + expandVertically(),
|
||||
exit = fadeOut() + shrinkVertically()
|
||||
) {
|
||||
OutlinedFillColorSelector(
|
||||
value = value.outlinedFillColor?.toColor(),
|
||||
onValueChange = {
|
||||
onValueChange(value.updateOutlined(it))
|
||||
},
|
||||
shape = ShapeDefaults.top,
|
||||
modifier = Modifier
|
||||
.padding(bottom = 4.dp)
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 8.dp),
|
||||
containerColor = MaterialTheme.colorScheme.surface,
|
||||
)
|
||||
}
|
||||
EnhancedSliderItem(
|
||||
value = value.vertices(),
|
||||
title = stringResource(R.string.vertices),
|
||||
valueRange = 3f..24f,
|
||||
steps = 20,
|
||||
internalStateTransformation = {
|
||||
it.roundToInt()
|
||||
},
|
||||
onValueChange = {
|
||||
onValueChange(
|
||||
value.updatePolygon(vertices = it.toInt())
|
||||
)
|
||||
},
|
||||
containerColor = MaterialTheme.colorScheme.surface,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 8.dp),
|
||||
shape = if (canChangeFillColor) ShapeDefaults.center else ShapeDefaults.top
|
||||
)
|
||||
Spacer(modifier = Modifier.height(4.dp))
|
||||
EnhancedSliderItem(
|
||||
value = value.rotationDegrees(),
|
||||
title = stringResource(R.string.angle),
|
||||
valueRange = 0f..360f,
|
||||
internalStateTransformation = {
|
||||
it.roundToInt()
|
||||
},
|
||||
onValueChange = {
|
||||
onValueChange(
|
||||
value.updatePolygon(rotationDegrees = it.toInt())
|
||||
)
|
||||
},
|
||||
containerColor = MaterialTheme.colorScheme.surface,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 8.dp),
|
||||
shape = ShapeDefaults.center
|
||||
)
|
||||
Spacer(modifier = Modifier.height(4.dp))
|
||||
PreferenceRowSwitch(
|
||||
title = stringResource(R.string.draw_regular_polygon),
|
||||
subtitle = stringResource(R.string.draw_regular_polygon_sub),
|
||||
checked = value.isRegular(),
|
||||
onClick = {
|
||||
onValueChange(
|
||||
value.updatePolygon(isRegular = it)
|
||||
)
|
||||
},
|
||||
containerColor = MaterialTheme.colorScheme.surface,
|
||||
shape = ShapeDefaults.bottom,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 8.dp),
|
||||
startIcon = Icons.Rounded.SquareFoot,
|
||||
)
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
}
|
||||
}
|
||||
}
|
||||
+53
@@ -0,0 +1,53 @@
|
||||
/*
|
||||
* 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.draw.presentation.components.element
|
||||
|
||||
import androidx.compose.animation.AnimatedVisibility
|
||||
import androidx.compose.animation.expandVertically
|
||||
import androidx.compose.animation.fadeIn
|
||||
import androidx.compose.animation.fadeOut
|
||||
import androidx.compose.animation.shrinkVertically
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.t8rin.imagetoolbox.core.ui.widget.controls.resize_group.components.BlurRadiusSelector
|
||||
import com.t8rin.imagetoolbox.feature.draw.domain.DrawMode
|
||||
|
||||
@Composable
|
||||
internal fun PrivacyBlurParamsSelector(
|
||||
value: DrawMode,
|
||||
onValueChange: (DrawMode) -> Unit
|
||||
) {
|
||||
AnimatedVisibility(
|
||||
visible = value is DrawMode.PathEffect.PrivacyBlur,
|
||||
enter = fadeIn() + expandVertically(),
|
||||
exit = fadeOut() + shrinkVertically()
|
||||
) {
|
||||
BlurRadiusSelector(
|
||||
modifier = Modifier.padding(8.dp),
|
||||
value = (value as? DrawMode.PathEffect.PrivacyBlur)?.blurRadius ?: 0,
|
||||
valueRange = 5f..50f,
|
||||
onValueChange = {
|
||||
onValueChange(DrawMode.PathEffect.PrivacyBlur(it))
|
||||
},
|
||||
color = MaterialTheme.colorScheme.surface
|
||||
)
|
||||
}
|
||||
}
|
||||
+116
@@ -0,0 +1,116 @@
|
||||
/*
|
||||
* 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.draw.presentation.components.element
|
||||
|
||||
import androidx.compose.animation.AnimatedVisibility
|
||||
import androidx.compose.animation.expandVertically
|
||||
import androidx.compose.animation.fadeIn
|
||||
import androidx.compose.animation.fadeOut
|
||||
import androidx.compose.animation.shrinkVertically
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.t8rin.colors.util.roundToTwoDigits
|
||||
import com.t8rin.imagetoolbox.core.resources.R
|
||||
import com.t8rin.imagetoolbox.core.ui.utils.helper.toColor
|
||||
import com.t8rin.imagetoolbox.core.ui.widget.enhanced.EnhancedSliderItem
|
||||
import com.t8rin.imagetoolbox.core.ui.widget.modifier.ShapeDefaults
|
||||
import com.t8rin.imagetoolbox.feature.draw.domain.DrawPathMode
|
||||
import com.t8rin.imagetoolbox.feature.draw.presentation.components.utils.cornerRadius
|
||||
import com.t8rin.imagetoolbox.feature.draw.presentation.components.utils.isRect
|
||||
import com.t8rin.imagetoolbox.feature.draw.presentation.components.utils.rotationDegrees
|
||||
import com.t8rin.imagetoolbox.feature.draw.presentation.components.utils.updateOutlined
|
||||
import com.t8rin.imagetoolbox.feature.draw.presentation.components.utils.updateRect
|
||||
import kotlin.math.roundToInt
|
||||
|
||||
@Composable
|
||||
internal fun RectParamsSelector(
|
||||
value: DrawPathMode,
|
||||
onValueChange: (DrawPathMode) -> Unit,
|
||||
canChangeFillColor: Boolean
|
||||
) {
|
||||
AnimatedVisibility(
|
||||
visible = value.isRect(),
|
||||
enter = fadeIn() + expandVertically(),
|
||||
exit = fadeOut() + shrinkVertically()
|
||||
) {
|
||||
Column {
|
||||
AnimatedVisibility(
|
||||
visible = canChangeFillColor,
|
||||
enter = fadeIn() + expandVertically(),
|
||||
exit = fadeOut() + shrinkVertically()
|
||||
) {
|
||||
OutlinedFillColorSelector(
|
||||
value = value.outlinedFillColor?.toColor(),
|
||||
onValueChange = {
|
||||
onValueChange(value.updateOutlined(it))
|
||||
},
|
||||
shape = ShapeDefaults.top,
|
||||
modifier = Modifier
|
||||
.padding(bottom = 4.dp)
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 8.dp),
|
||||
containerColor = MaterialTheme.colorScheme.surface,
|
||||
)
|
||||
}
|
||||
EnhancedSliderItem(
|
||||
value = value.rotationDegrees(),
|
||||
title = stringResource(R.string.angle),
|
||||
valueRange = 0f..360f,
|
||||
internalStateTransformation = {
|
||||
it.roundToInt()
|
||||
},
|
||||
onValueChange = {
|
||||
onValueChange(
|
||||
value.updateRect(rotationDegrees = it.toInt())
|
||||
)
|
||||
},
|
||||
containerColor = MaterialTheme.colorScheme.surface,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 8.dp),
|
||||
shape = if (canChangeFillColor) ShapeDefaults.center else ShapeDefaults.top
|
||||
)
|
||||
Spacer(modifier = Modifier.height(4.dp))
|
||||
EnhancedSliderItem(
|
||||
value = value.cornerRadius(),
|
||||
title = stringResource(R.string.radius),
|
||||
valueRange = 0f..0.5f,
|
||||
internalStateTransformation = { it.roundToTwoDigits() },
|
||||
onValueChange = {
|
||||
onValueChange(
|
||||
value.updateRect(cornerRadius = it.roundToTwoDigits())
|
||||
)
|
||||
},
|
||||
containerColor = MaterialTheme.colorScheme.surface,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 8.dp),
|
||||
shape = ShapeDefaults.bottom
|
||||
)
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
}
|
||||
}
|
||||
}
|
||||
+188
@@ -0,0 +1,188 @@
|
||||
/*
|
||||
* 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.draw.presentation.components.element
|
||||
|
||||
import androidx.compose.animation.AnimatedVisibility
|
||||
import androidx.compose.animation.expandVertically
|
||||
import androidx.compose.animation.fadeIn
|
||||
import androidx.compose.animation.fadeOut
|
||||
import androidx.compose.animation.shrinkVertically
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
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.retain.retain
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.t8rin.imagetoolbox.core.domain.remote.DownloadProgress
|
||||
import com.t8rin.imagetoolbox.core.domain.saving.track
|
||||
import com.t8rin.imagetoolbox.core.domain.saving.updateProgress
|
||||
import com.t8rin.imagetoolbox.core.domain.utils.throttleLatest
|
||||
import com.t8rin.imagetoolbox.core.filters.domain.model.enums.SpotHealMode
|
||||
import com.t8rin.imagetoolbox.core.filters.presentation.utils.LamaLoader
|
||||
import com.t8rin.imagetoolbox.core.resources.R
|
||||
import com.t8rin.imagetoolbox.core.settings.presentation.provider.LocalSettingsState
|
||||
import com.t8rin.imagetoolbox.core.settings.presentation.provider.LocalSimpleSettingsInteractor
|
||||
import com.t8rin.imagetoolbox.core.ui.utils.helper.AppToastHost
|
||||
import com.t8rin.imagetoolbox.core.ui.utils.provider.LocalKeepAliveService
|
||||
import com.t8rin.imagetoolbox.core.ui.widget.enhanced.EnhancedCancellableCircularProgressIndicator
|
||||
import com.t8rin.imagetoolbox.core.ui.widget.modifier.ShapeDefaults
|
||||
import com.t8rin.imagetoolbox.core.ui.widget.preferences.PreferenceRowSwitch
|
||||
import com.t8rin.imagetoolbox.core.utils.getString
|
||||
import com.t8rin.imagetoolbox.feature.draw.domain.DrawMode
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.flow.catch
|
||||
import kotlinx.coroutines.flow.onCompletion
|
||||
import kotlinx.coroutines.flow.onEach
|
||||
import kotlinx.coroutines.flow.onStart
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlin.math.roundToInt
|
||||
|
||||
@Composable
|
||||
internal fun SpotHealParamsSelector(
|
||||
value: DrawMode,
|
||||
onValueChange: (DrawMode) -> Unit
|
||||
) {
|
||||
AnimatedVisibility(
|
||||
visible = value is DrawMode.SpotHeal,
|
||||
enter = fadeIn() + expandVertically(),
|
||||
exit = fadeOut() + shrinkVertically()
|
||||
) {
|
||||
val keepAliveService = LocalKeepAliveService.current
|
||||
val settingsState = LocalSettingsState.current
|
||||
val scope = retain { CoroutineScope(Dispatchers.IO) }
|
||||
val simpleSettingsInteractor = LocalSimpleSettingsInteractor.current
|
||||
var downloadJob by retain {
|
||||
mutableStateOf<Job?>(null)
|
||||
}
|
||||
var downloadProgress by remember(LamaLoader.isDownloaded) {
|
||||
mutableStateOf<DownloadProgress?>(null)
|
||||
}
|
||||
var useLama by remember(settingsState.spotHealMode) {
|
||||
mutableStateOf(settingsState.spotHealMode == 1)
|
||||
}
|
||||
LaunchedEffect(LamaLoader.isDownloaded) {
|
||||
if (!LamaLoader.isDownloaded) {
|
||||
useLama = false
|
||||
simpleSettingsInteractor.setSpotHealMode(0)
|
||||
}
|
||||
}
|
||||
|
||||
LaunchedEffect(useLama) {
|
||||
onValueChange(
|
||||
DrawMode.SpotHeal(
|
||||
if (useLama) SpotHealMode.LaMa else SpotHealMode.OpenCV
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
PreferenceRowSwitch(
|
||||
title = stringResource(R.string.generative_inpaint),
|
||||
subtitle = stringResource(
|
||||
if (LamaLoader.isDownloaded) R.string.generative_inpaint_ready_sub
|
||||
else R.string.generative_inpaint_sub
|
||||
),
|
||||
checked = useLama,
|
||||
onClick = { new ->
|
||||
if (downloadJob == null) {
|
||||
useLama = new
|
||||
|
||||
scope.launch { simpleSettingsInteractor.setSpotHealMode(if (useLama) 1 else 0) }
|
||||
|
||||
if (useLama && !LamaLoader.isDownloaded) {
|
||||
downloadJob?.cancel()
|
||||
downloadJob = scope.launch {
|
||||
keepAliveService.track(
|
||||
initial = {
|
||||
updateOrStart(
|
||||
title = getString(R.string.downloading)
|
||||
)
|
||||
}
|
||||
) {
|
||||
LamaLoader.download()
|
||||
.onStart {
|
||||
downloadProgress = DownloadProgress(
|
||||
currentPercent = 0f,
|
||||
currentTotalSize = 0
|
||||
)
|
||||
}
|
||||
.onCompletion {
|
||||
downloadProgress = null
|
||||
downloadJob = null
|
||||
}
|
||||
.catch {
|
||||
simpleSettingsInteractor.setSpotHealMode(0)
|
||||
AppToastHost.showFailureToast(it)
|
||||
useLama = false
|
||||
downloadProgress = null
|
||||
downloadJob = null
|
||||
}
|
||||
.onEach {
|
||||
downloadProgress = it
|
||||
}
|
||||
.throttleLatest(50)
|
||||
.collect {
|
||||
updateProgress(
|
||||
title = getString(R.string.downloading),
|
||||
done = (it.currentPercent * 100).roundToInt(),
|
||||
total = 100
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
contentInsteadOfSwitch = downloadProgress?.let { progress ->
|
||||
{
|
||||
EnhancedCancellableCircularProgressIndicator(
|
||||
progress = { progress.currentPercent },
|
||||
modifier = Modifier.size(24.dp),
|
||||
trackColor = MaterialTheme.colorScheme.primary.copy(0.2f),
|
||||
strokeWidth = 3.dp,
|
||||
onCancel = {
|
||||
downloadJob?.cancel()
|
||||
downloadProgress = null
|
||||
downloadJob = null
|
||||
useLama = false
|
||||
scope.launch {
|
||||
simpleSettingsInteractor.setSpotHealMode(0)
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
},
|
||||
containerColor = MaterialTheme.colorScheme.surface,
|
||||
shape = ShapeDefaults.default,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(8.dp),
|
||||
resultModifier = Modifier.padding(16.dp),
|
||||
applyHorizontalPadding = false
|
||||
)
|
||||
}
|
||||
}
|
||||
+119
@@ -0,0 +1,119 @@
|
||||
/*
|
||||
* 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.draw.presentation.components.element
|
||||
|
||||
import androidx.compose.animation.AnimatedVisibility
|
||||
import androidx.compose.animation.expandVertically
|
||||
import androidx.compose.animation.fadeIn
|
||||
import androidx.compose.animation.fadeOut
|
||||
import androidx.compose.animation.shrinkVertically
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.t8rin.colors.util.roundToTwoDigits
|
||||
import com.t8rin.imagetoolbox.core.resources.Icons
|
||||
import com.t8rin.imagetoolbox.core.resources.R
|
||||
import com.t8rin.imagetoolbox.core.resources.icons.Square
|
||||
import com.t8rin.imagetoolbox.core.ui.widget.enhanced.EnhancedSliderItem
|
||||
import com.t8rin.imagetoolbox.core.ui.widget.modifier.ShapeDefaults
|
||||
import com.t8rin.imagetoolbox.core.ui.widget.preferences.PreferenceRowSwitch
|
||||
import com.t8rin.imagetoolbox.feature.draw.domain.DrawPathMode
|
||||
import com.t8rin.imagetoolbox.feature.draw.presentation.components.utils.density
|
||||
import com.t8rin.imagetoolbox.feature.draw.presentation.components.utils.isSpray
|
||||
import com.t8rin.imagetoolbox.feature.draw.presentation.components.utils.isSquareShaped
|
||||
import com.t8rin.imagetoolbox.feature.draw.presentation.components.utils.pixelSize
|
||||
import com.t8rin.imagetoolbox.feature.draw.presentation.components.utils.updateSpray
|
||||
import kotlin.math.roundToInt
|
||||
|
||||
@Composable
|
||||
internal fun SprayParamsSelector(
|
||||
value: DrawPathMode,
|
||||
onValueChange: (DrawPathMode) -> Unit
|
||||
) {
|
||||
AnimatedVisibility(
|
||||
visible = value.isSpray(),
|
||||
enter = fadeIn() + expandVertically(),
|
||||
exit = fadeOut() + shrinkVertically()
|
||||
) {
|
||||
Column {
|
||||
EnhancedSliderItem(
|
||||
value = value.density(),
|
||||
title = stringResource(R.string.density),
|
||||
valueRange = 1f..500f,
|
||||
internalStateTransformation = {
|
||||
it.roundToInt()
|
||||
},
|
||||
onValueChange = {
|
||||
onValueChange(
|
||||
value.updateSpray(density = it.roundToInt())
|
||||
)
|
||||
},
|
||||
containerColor = MaterialTheme.colorScheme.surface,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 8.dp),
|
||||
shape = ShapeDefaults.top
|
||||
)
|
||||
Spacer(modifier = Modifier.height(4.dp))
|
||||
EnhancedSliderItem(
|
||||
value = value.pixelSize(),
|
||||
title = stringResource(R.string.pixel_size),
|
||||
valueRange = 0.5f..20f,
|
||||
internalStateTransformation = {
|
||||
it.roundToTwoDigits()
|
||||
},
|
||||
onValueChange = {
|
||||
onValueChange(
|
||||
value.updateSpray(pixelSize = it.roundToTwoDigits())
|
||||
)
|
||||
},
|
||||
containerColor = MaterialTheme.colorScheme.surface,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 8.dp),
|
||||
shape = ShapeDefaults.center
|
||||
)
|
||||
Spacer(modifier = Modifier.height(4.dp))
|
||||
PreferenceRowSwitch(
|
||||
title = stringResource(R.string.square_particles),
|
||||
subtitle = stringResource(R.string.square_particles_sub),
|
||||
checked = value.isSquareShaped(),
|
||||
onClick = {
|
||||
onValueChange(
|
||||
value.updateSpray(isSquareShaped = it)
|
||||
)
|
||||
},
|
||||
containerColor = MaterialTheme.colorScheme.surface,
|
||||
shape = ShapeDefaults.bottom,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 8.dp),
|
||||
resultModifier = Modifier.padding(16.dp),
|
||||
startIcon = Icons.Rounded.Square
|
||||
)
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
}
|
||||
}
|
||||
}
|
||||
+160
@@ -0,0 +1,160 @@
|
||||
/*
|
||||
* 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.draw.presentation.components.element
|
||||
|
||||
import androidx.compose.animation.AnimatedVisibility
|
||||
import androidx.compose.animation.expandVertically
|
||||
import androidx.compose.animation.fadeIn
|
||||
import androidx.compose.animation.fadeOut
|
||||
import androidx.compose.animation.shrinkVertically
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
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.Modifier
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.t8rin.colors.util.roundToTwoDigits
|
||||
import com.t8rin.imagetoolbox.core.resources.R
|
||||
import com.t8rin.imagetoolbox.core.resources.icons.SquareFoot
|
||||
import com.t8rin.imagetoolbox.core.ui.utils.helper.toColor
|
||||
import com.t8rin.imagetoolbox.core.ui.widget.enhanced.EnhancedSliderItem
|
||||
import com.t8rin.imagetoolbox.core.ui.widget.modifier.ShapeDefaults
|
||||
import com.t8rin.imagetoolbox.core.ui.widget.preferences.PreferenceRowSwitch
|
||||
import com.t8rin.imagetoolbox.feature.draw.domain.DrawPathMode
|
||||
import com.t8rin.imagetoolbox.feature.draw.presentation.components.utils.innerRadiusRatio
|
||||
import com.t8rin.imagetoolbox.feature.draw.presentation.components.utils.isRegular
|
||||
import com.t8rin.imagetoolbox.feature.draw.presentation.components.utils.isStar
|
||||
import com.t8rin.imagetoolbox.feature.draw.presentation.components.utils.rotationDegrees
|
||||
import com.t8rin.imagetoolbox.feature.draw.presentation.components.utils.updateOutlined
|
||||
import com.t8rin.imagetoolbox.feature.draw.presentation.components.utils.updateStar
|
||||
import com.t8rin.imagetoolbox.feature.draw.presentation.components.utils.vertices
|
||||
import kotlin.math.roundToInt
|
||||
|
||||
@Composable
|
||||
internal fun StarParamsSelector(
|
||||
value: DrawPathMode,
|
||||
onValueChange: (DrawPathMode) -> Unit,
|
||||
canChangeFillColor: Boolean
|
||||
) {
|
||||
AnimatedVisibility(
|
||||
visible = value.isStar(),
|
||||
enter = fadeIn() + expandVertically(),
|
||||
exit = fadeOut() + shrinkVertically()
|
||||
) {
|
||||
Column {
|
||||
AnimatedVisibility(
|
||||
visible = canChangeFillColor,
|
||||
enter = fadeIn() + expandVertically(),
|
||||
exit = fadeOut() + shrinkVertically()
|
||||
) {
|
||||
OutlinedFillColorSelector(
|
||||
value = value.outlinedFillColor?.toColor(),
|
||||
onValueChange = {
|
||||
onValueChange(value.updateOutlined(it))
|
||||
},
|
||||
shape = ShapeDefaults.top,
|
||||
modifier = Modifier
|
||||
.padding(bottom = 4.dp)
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 8.dp),
|
||||
containerColor = MaterialTheme.colorScheme.surface,
|
||||
)
|
||||
}
|
||||
EnhancedSliderItem(
|
||||
value = value.vertices(),
|
||||
title = stringResource(R.string.vertices),
|
||||
valueRange = 3f..24f,
|
||||
steps = 20,
|
||||
internalStateTransformation = {
|
||||
it.roundToInt()
|
||||
},
|
||||
onValueChange = {
|
||||
onValueChange(
|
||||
value.updateStar(vertices = it.toInt())
|
||||
)
|
||||
},
|
||||
containerColor = MaterialTheme.colorScheme.surface,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 8.dp),
|
||||
shape = if (canChangeFillColor) ShapeDefaults.center else ShapeDefaults.top
|
||||
)
|
||||
Spacer(modifier = Modifier.height(4.dp))
|
||||
EnhancedSliderItem(
|
||||
value = value.rotationDegrees(),
|
||||
title = stringResource(R.string.angle),
|
||||
valueRange = 0f..360f,
|
||||
internalStateTransformation = {
|
||||
it.roundToInt()
|
||||
},
|
||||
onValueChange = {
|
||||
onValueChange(
|
||||
value.updateStar(rotationDegrees = it.toInt())
|
||||
)
|
||||
},
|
||||
containerColor = MaterialTheme.colorScheme.surface,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 8.dp),
|
||||
shape = ShapeDefaults.center
|
||||
)
|
||||
Spacer(modifier = Modifier.height(4.dp))
|
||||
EnhancedSliderItem(
|
||||
value = value.innerRadiusRatio(),
|
||||
title = stringResource(R.string.inner_radius_ratio),
|
||||
valueRange = 0f..1f,
|
||||
internalStateTransformation = {
|
||||
it.roundToTwoDigits()
|
||||
},
|
||||
onValueChange = {
|
||||
onValueChange(
|
||||
value.updateStar(innerRadiusRatio = it)
|
||||
)
|
||||
},
|
||||
containerColor = MaterialTheme.colorScheme.surface,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 8.dp),
|
||||
shape = ShapeDefaults.center
|
||||
)
|
||||
Spacer(modifier = Modifier.height(4.dp))
|
||||
PreferenceRowSwitch(
|
||||
title = stringResource(R.string.draw_regular_star),
|
||||
subtitle = stringResource(R.string.draw_regular_star_sub),
|
||||
checked = value.isRegular(),
|
||||
onClick = {
|
||||
onValueChange(
|
||||
value.updateStar(isRegular = it)
|
||||
)
|
||||
},
|
||||
containerColor = MaterialTheme.colorScheme.surface,
|
||||
shape = ShapeDefaults.bottom,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 8.dp),
|
||||
startIcon = Icons.Rounded.SquareFoot,
|
||||
)
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
}
|
||||
}
|
||||
}
|
||||
+154
@@ -0,0 +1,154 @@
|
||||
/*
|
||||
* 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.draw.presentation.components.element
|
||||
|
||||
import androidx.compose.animation.AnimatedVisibility
|
||||
import androidx.compose.animation.expandVertically
|
||||
import androidx.compose.animation.fadeIn
|
||||
import androidx.compose.animation.fadeOut
|
||||
import androidx.compose.animation.shrinkVertically
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
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.unit.dp
|
||||
import com.t8rin.colors.util.roundToTwoDigits
|
||||
import com.t8rin.imagetoolbox.core.domain.model.pt
|
||||
import com.t8rin.imagetoolbox.core.resources.R
|
||||
import com.t8rin.imagetoolbox.core.settings.presentation.model.toUiFont
|
||||
import com.t8rin.imagetoolbox.core.ui.widget.controls.selection.FontSelector
|
||||
import com.t8rin.imagetoolbox.core.ui.widget.enhanced.EnhancedSliderItem
|
||||
import com.t8rin.imagetoolbox.core.ui.widget.modifier.ShapeDefaults
|
||||
import com.t8rin.imagetoolbox.core.ui.widget.modifier.animateShape
|
||||
import com.t8rin.imagetoolbox.core.ui.widget.modifier.container
|
||||
import com.t8rin.imagetoolbox.core.ui.widget.preferences.PreferenceRowSwitch
|
||||
import com.t8rin.imagetoolbox.core.ui.widget.text.RoundedTextField
|
||||
import com.t8rin.imagetoolbox.feature.draw.domain.DrawMode
|
||||
|
||||
@Composable
|
||||
internal fun TextParamsSelector(
|
||||
value: DrawMode,
|
||||
onValueChange: (DrawMode) -> Unit
|
||||
) {
|
||||
AnimatedVisibility(
|
||||
visible = value is DrawMode.Text,
|
||||
enter = fadeIn() + expandVertically(),
|
||||
exit = fadeOut() + shrinkVertically()
|
||||
) {
|
||||
Column {
|
||||
RoundedTextField(
|
||||
modifier = Modifier
|
||||
.padding(horizontal = 8.dp)
|
||||
.container(
|
||||
shape = ShapeDefaults.top,
|
||||
color = MaterialTheme.colorScheme.surface,
|
||||
resultPadding = 8.dp
|
||||
),
|
||||
value = (value as? DrawMode.Text)?.text ?: "",
|
||||
singleLine = false,
|
||||
onValueChange = {
|
||||
onValueChange(
|
||||
(value as? DrawMode.Text)?.copy(
|
||||
text = it
|
||||
) ?: value
|
||||
)
|
||||
},
|
||||
label = {
|
||||
Text(stringResource(R.string.text))
|
||||
}
|
||||
)
|
||||
Spacer(modifier = Modifier.height(4.dp))
|
||||
FontSelector(
|
||||
value = (value as? DrawMode.Text)?.font.toUiFont(),
|
||||
onValueChange = {
|
||||
onValueChange(
|
||||
(value as? DrawMode.Text)?.copy(
|
||||
font = it.type
|
||||
) ?: value
|
||||
)
|
||||
},
|
||||
modifier = Modifier
|
||||
.padding(horizontal = 8.dp),
|
||||
shape = ShapeDefaults.center
|
||||
)
|
||||
Spacer(modifier = Modifier.height(4.dp))
|
||||
val isDashSizeControlVisible = (value as? DrawMode.Text)?.isRepeated == true
|
||||
PreferenceRowSwitch(
|
||||
title = stringResource(R.string.repeat_text),
|
||||
subtitle = stringResource(R.string.repeat_text_sub),
|
||||
checked = (value as? DrawMode.Text)?.isRepeated == true,
|
||||
onClick = {
|
||||
onValueChange(
|
||||
(value as? DrawMode.Text)?.copy(
|
||||
isRepeated = it
|
||||
) ?: value
|
||||
)
|
||||
},
|
||||
containerColor = MaterialTheme.colorScheme.surface,
|
||||
shape = animateShape(
|
||||
if (isDashSizeControlVisible) ShapeDefaults.center
|
||||
else ShapeDefaults.bottom
|
||||
),
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 8.dp),
|
||||
resultModifier = Modifier.padding(16.dp),
|
||||
applyHorizontalPadding = false
|
||||
)
|
||||
Spacer(
|
||||
modifier = Modifier.height(
|
||||
if (isDashSizeControlVisible) 4.dp else 8.dp
|
||||
)
|
||||
)
|
||||
AnimatedVisibility(
|
||||
visible = isDashSizeControlVisible,
|
||||
enter = fadeIn() + expandVertically(),
|
||||
exit = fadeOut() + shrinkVertically()
|
||||
) {
|
||||
EnhancedSliderItem(
|
||||
value = (value as? DrawMode.Text)?.repeatingInterval?.value ?: 0f,
|
||||
title = stringResource(R.string.dash_size),
|
||||
valueRange = 0f..100f,
|
||||
internalStateTransformation = {
|
||||
it.roundToTwoDigits()
|
||||
},
|
||||
onValueChange = {
|
||||
onValueChange(
|
||||
(value as? DrawMode.Text)?.copy(
|
||||
repeatingInterval = it.pt
|
||||
) ?: value
|
||||
)
|
||||
},
|
||||
containerColor = MaterialTheme.colorScheme.surface,
|
||||
valueSuffix = " Pt",
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 8.dp)
|
||||
.padding(bottom = 8.dp),
|
||||
shape = ShapeDefaults.bottom
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+65
@@ -0,0 +1,65 @@
|
||||
/*
|
||||
* 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.draw.presentation.components.element
|
||||
|
||||
import androidx.compose.animation.AnimatedVisibility
|
||||
import androidx.compose.animation.expandVertically
|
||||
import androidx.compose.animation.fadeIn
|
||||
import androidx.compose.animation.fadeOut
|
||||
import androidx.compose.animation.shrinkVertically
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.t8rin.imagetoolbox.core.ui.utils.helper.toColor
|
||||
import com.t8rin.imagetoolbox.core.ui.widget.modifier.ShapeDefaults
|
||||
import com.t8rin.imagetoolbox.feature.draw.domain.DrawPathMode
|
||||
import com.t8rin.imagetoolbox.feature.draw.presentation.components.utils.updateOutlined
|
||||
|
||||
@Composable
|
||||
internal fun TriangleParamsSelector(
|
||||
value: DrawPathMode,
|
||||
onValueChange: (DrawPathMode) -> Unit,
|
||||
canChangeFillColor: Boolean
|
||||
) {
|
||||
AnimatedVisibility(
|
||||
visible = value is DrawPathMode.OutlinedTriangle && canChangeFillColor,
|
||||
enter = fadeIn() + expandVertically(),
|
||||
exit = fadeOut() + shrinkVertically()
|
||||
) {
|
||||
Column {
|
||||
OutlinedFillColorSelector(
|
||||
value = value.outlinedFillColor?.toColor(),
|
||||
onValueChange = {
|
||||
onValueChange(value.updateOutlined(it))
|
||||
},
|
||||
shape = ShapeDefaults.default,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 8.dp),
|
||||
containerColor = MaterialTheme.colorScheme.surface,
|
||||
)
|
||||
Spacer(Modifier.height(8.dp))
|
||||
}
|
||||
}
|
||||
}
|
||||
+163
@@ -0,0 +1,163 @@
|
||||
/*
|
||||
* 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.draw.presentation.components.element
|
||||
|
||||
import androidx.compose.animation.AnimatedVisibility
|
||||
import androidx.compose.animation.expandVertically
|
||||
import androidx.compose.animation.fadeIn
|
||||
import androidx.compose.animation.fadeOut
|
||||
import androidx.compose.animation.shrinkVertically
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Surface
|
||||
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.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.t8rin.colors.util.roundToTwoDigits
|
||||
import com.t8rin.imagetoolbox.core.domain.utils.safeCast
|
||||
import com.t8rin.imagetoolbox.core.resources.R
|
||||
import com.t8rin.imagetoolbox.core.ui.theme.ImageToolboxThemeForPreview
|
||||
import com.t8rin.imagetoolbox.core.ui.widget.enhanced.EnhancedButtonGroup
|
||||
import com.t8rin.imagetoolbox.core.ui.widget.enhanced.EnhancedSliderItem
|
||||
import com.t8rin.imagetoolbox.core.ui.widget.modifier.ShapeDefaults
|
||||
import com.t8rin.imagetoolbox.core.ui.widget.modifier.container
|
||||
import com.t8rin.imagetoolbox.feature.draw.domain.DrawMode
|
||||
import com.t8rin.imagetoolbox.feature.draw.domain.WarpMode
|
||||
|
||||
@Composable
|
||||
internal fun WarpParamsSelector(
|
||||
value: DrawMode,
|
||||
onValueChange: (DrawMode) -> Unit
|
||||
) {
|
||||
AnimatedVisibility(
|
||||
visible = value is DrawMode.Warp,
|
||||
enter = fadeIn() + expandVertically(),
|
||||
exit = fadeOut() + shrinkVertically()
|
||||
) {
|
||||
Column {
|
||||
EnhancedSliderItem(
|
||||
value = value.safeCast<DrawMode.Warp>()?.strength ?: 0f,
|
||||
title = stringResource(R.string.strength),
|
||||
valueRange = 0f..1f,
|
||||
internalStateTransformation = {
|
||||
it.roundToTwoDigits()
|
||||
},
|
||||
onValueChange = {
|
||||
onValueChange(
|
||||
value.safeCast<DrawMode.Warp>()?.copy(
|
||||
strength = it.roundToTwoDigits()
|
||||
) ?: value
|
||||
)
|
||||
},
|
||||
containerColor = MaterialTheme.colorScheme.surface,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 8.dp),
|
||||
shape = ShapeDefaults.top
|
||||
)
|
||||
Spacer(modifier = Modifier.height(4.dp))
|
||||
EnhancedSliderItem(
|
||||
value = value.safeCast<DrawMode.Warp>()?.hardness ?: 0f,
|
||||
title = stringResource(R.string.hardness),
|
||||
valueRange = 0f..1f,
|
||||
internalStateTransformation = {
|
||||
it.roundToTwoDigits()
|
||||
},
|
||||
onValueChange = {
|
||||
onValueChange(
|
||||
value.safeCast<DrawMode.Warp>()?.copy(
|
||||
hardness = it.roundToTwoDigits()
|
||||
) ?: value
|
||||
)
|
||||
},
|
||||
containerColor = MaterialTheme.colorScheme.surface,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 8.dp),
|
||||
shape = ShapeDefaults.center
|
||||
)
|
||||
Spacer(modifier = Modifier.height(4.dp))
|
||||
EnhancedButtonGroup(
|
||||
enabled = true,
|
||||
modifier = Modifier
|
||||
.padding(horizontal = 8.dp)
|
||||
.container(
|
||||
shape = ShapeDefaults.bottom,
|
||||
color = MaterialTheme.colorScheme.surface
|
||||
),
|
||||
itemCount = WarpMode.entries.size,
|
||||
title = {
|
||||
Text(
|
||||
text = stringResource(R.string.warp_mode),
|
||||
textAlign = TextAlign.Center,
|
||||
fontWeight = FontWeight.Medium,
|
||||
modifier = Modifier.padding(top = 4.dp)
|
||||
)
|
||||
},
|
||||
selectedIndex = WarpMode.entries.indexOfFirst {
|
||||
value.safeCast<DrawMode.Warp>()?.warpMode == it
|
||||
},
|
||||
itemContent = {
|
||||
Text(
|
||||
text = stringResource(WarpMode.entries[it].title())
|
||||
)
|
||||
},
|
||||
onIndexChange = {
|
||||
onValueChange(
|
||||
value.safeCast<DrawMode.Warp>()?.copy(
|
||||
warpMode = WarpMode.entries[it]
|
||||
) ?: value
|
||||
)
|
||||
},
|
||||
inactiveButtonColor = MaterialTheme.colorScheme.surfaceContainer
|
||||
)
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun WarpMode.title(): Int = when (this) {
|
||||
WarpMode.MOVE -> R.string.warp_mode_move
|
||||
WarpMode.GROW -> R.string.warp_mode_grow
|
||||
WarpMode.SHRINK -> R.string.warp_mode_shrink
|
||||
WarpMode.SWIRL_CW -> R.string.warp_mode_swirl_cw
|
||||
WarpMode.SWIRL_CCW -> R.string.warp_mode_swirl_ccw
|
||||
WarpMode.MIXING -> R.string.mix
|
||||
}
|
||||
|
||||
@Composable
|
||||
@Preview
|
||||
private fun Preview() = ImageToolboxThemeForPreview(true) {
|
||||
Surface(
|
||||
color = MaterialTheme.colorScheme.surfaceContainer
|
||||
) {
|
||||
WarpParamsSelector(
|
||||
value = DrawMode.Warp(),
|
||||
onValueChange = {}
|
||||
)
|
||||
}
|
||||
}
|
||||
+79
@@ -0,0 +1,79 @@
|
||||
/*
|
||||
* 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.draw.presentation.components.utils
|
||||
|
||||
import androidx.compose.foundation.border
|
||||
import androidx.compose.foundation.layout.BoxScope
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.MutableIntState
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.geometry.Offset
|
||||
import androidx.compose.ui.graphics.ImageBitmap
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.t8rin.imagetoolbox.core.ui.theme.outlineVariant
|
||||
import com.t8rin.imagetoolbox.core.ui.widget.image.Picture
|
||||
import com.t8rin.imagetoolbox.core.ui.widget.modifier.HelperGridParams
|
||||
import com.t8rin.imagetoolbox.core.ui.widget.modifier.ShapeDefaults
|
||||
import com.t8rin.imagetoolbox.core.ui.widget.modifier.drawHelperGrid
|
||||
import com.t8rin.imagetoolbox.core.ui.widget.modifier.transparencyChecker
|
||||
|
||||
@Composable
|
||||
fun BoxScope.BitmapDrawerPreview(
|
||||
preview: ImageBitmap,
|
||||
globalTouchPointersCount: MutableIntState,
|
||||
onReceiveMotionEvent: (MotionEvent) -> Unit,
|
||||
onInvalidate: () -> Unit,
|
||||
onUpdateCurrentDrawPosition: (Offset) -> Unit,
|
||||
onUpdateDrawDownPosition: (Offset) -> Unit,
|
||||
drawEnabled: Boolean,
|
||||
helperGridParams: HelperGridParams,
|
||||
drawBitmapBorder: Boolean,
|
||||
beforeHelperGridModifier: Modifier = Modifier,
|
||||
) {
|
||||
Picture(
|
||||
model = preview,
|
||||
modifier = Modifier
|
||||
.matchParentSize()
|
||||
.pointerDrawHandler(
|
||||
globalTouchPointersCount = globalTouchPointersCount,
|
||||
onReceiveMotionEvent = onReceiveMotionEvent,
|
||||
onInvalidate = onInvalidate,
|
||||
onUpdateCurrentDrawPosition = onUpdateCurrentDrawPosition,
|
||||
onUpdateDrawDownPosition = onUpdateDrawDownPosition,
|
||||
enabled = drawEnabled
|
||||
)
|
||||
.clip(ShapeDefaults.extremeSmall)
|
||||
.transparencyChecker()
|
||||
.then(beforeHelperGridModifier)
|
||||
.drawHelperGrid(helperGridParams)
|
||||
.then(
|
||||
if (drawBitmapBorder) {
|
||||
Modifier.border(
|
||||
width = 1.dp,
|
||||
color = MaterialTheme.colorScheme.outlineVariant(),
|
||||
shape = ShapeDefaults.extremeSmall
|
||||
)
|
||||
} else Modifier
|
||||
),
|
||||
contentDescription = null,
|
||||
contentScale = ContentScale.FillBounds
|
||||
)
|
||||
}
|
||||
+103
@@ -0,0 +1,103 @@
|
||||
/*
|
||||
* 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.draw.presentation.components.utils
|
||||
|
||||
import android.graphics.Bitmap
|
||||
import android.graphics.PorterDuff
|
||||
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.setValue
|
||||
import androidx.compose.ui.geometry.Offset
|
||||
import androidx.compose.ui.graphics.Canvas
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.ImageBitmap
|
||||
import androidx.compose.ui.graphics.Paint
|
||||
import androidx.compose.ui.graphics.Path
|
||||
import androidx.compose.ui.graphics.asAndroidBitmap
|
||||
import androidx.compose.ui.graphics.asImageBitmap
|
||||
import androidx.compose.ui.graphics.nativeCanvas
|
||||
import androidx.compose.ui.graphics.toArgb
|
||||
import com.t8rin.imagetoolbox.core.domain.model.IntegerSize
|
||||
import com.t8rin.imagetoolbox.core.domain.model.Pt
|
||||
import com.t8rin.imagetoolbox.core.filters.domain.model.Filter
|
||||
import com.t8rin.imagetoolbox.core.ui.utils.helper.ImageUtils.createScaledBitmap
|
||||
import com.t8rin.imagetoolbox.feature.draw.domain.DrawMode
|
||||
import com.t8rin.imagetoolbox.feature.draw.domain.DrawPathMode
|
||||
import com.t8rin.imagetoolbox.feature.draw.presentation.components.UiPathPaint
|
||||
import android.graphics.Path as NativePath
|
||||
|
||||
@Composable
|
||||
internal fun DrawPathEffectPreview(
|
||||
drawPathCanvas: Canvas,
|
||||
drawMode: DrawMode.PathEffect,
|
||||
canvasSize: IntegerSize,
|
||||
imageWidth: Int,
|
||||
imageHeight: Int,
|
||||
outputImage: ImageBitmap,
|
||||
onRequestFiltering: suspend (Bitmap, List<Filter<*>>) -> Bitmap?,
|
||||
paths: List<UiPathPaint>,
|
||||
drawPath: Path,
|
||||
backgroundColor: Color,
|
||||
strokeWidth: Pt,
|
||||
drawPathMode: DrawPathMode
|
||||
) {
|
||||
var shaderBitmap by remember {
|
||||
mutableStateOf<ImageBitmap?>(null)
|
||||
}
|
||||
|
||||
LaunchedEffect(outputImage, paths, backgroundColor, drawMode) {
|
||||
shaderBitmap = onRequestFiltering(
|
||||
outputImage.asAndroidBitmap(),
|
||||
transformationsForMode(
|
||||
drawMode = drawMode,
|
||||
canvasSize = canvasSize
|
||||
)
|
||||
)?.createScaledBitmap(
|
||||
width = imageWidth,
|
||||
height = imageHeight
|
||||
)?.asImageBitmap()
|
||||
}
|
||||
|
||||
shaderBitmap?.let {
|
||||
with(drawPathCanvas) {
|
||||
with(nativeCanvas) {
|
||||
drawColor(Color.Transparent.toArgb(), PorterDuff.Mode.CLEAR)
|
||||
|
||||
val paint by rememberPathEffectPaint(
|
||||
strokeWidth = strokeWidth,
|
||||
drawPathMode = drawPathMode,
|
||||
canvasSize = canvasSize
|
||||
)
|
||||
val newPath = drawPath.copyAsAndroidPath().apply {
|
||||
fillType = NativePath.FillType.INVERSE_WINDING
|
||||
}
|
||||
val imagePaint = remember { Paint() }
|
||||
|
||||
drawImage(
|
||||
image = it,
|
||||
topLeftOffset = Offset.Zero,
|
||||
paint = imagePaint
|
||||
)
|
||||
drawPath(newPath, paint)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+523
@@ -0,0 +1,523 @@
|
||||
/*
|
||||
* 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.draw.presentation.components.utils
|
||||
|
||||
import com.t8rin.imagetoolbox.core.resources.Icons
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import com.t8rin.imagetoolbox.core.data.image.utils.ColorUtils.toColor
|
||||
import com.t8rin.imagetoolbox.core.data.image.utils.ColorUtils.toModel
|
||||
import com.t8rin.imagetoolbox.core.resources.R
|
||||
import com.t8rin.imagetoolbox.core.resources.icons.CheckBoxOutlineBlank
|
||||
import com.t8rin.imagetoolbox.core.resources.icons.Circle
|
||||
import com.t8rin.imagetoolbox.core.resources.icons.FloodFill
|
||||
import com.t8rin.imagetoolbox.core.resources.icons.FreeArrow
|
||||
import com.t8rin.imagetoolbox.core.resources.icons.FreeDoubleArrow
|
||||
import com.t8rin.imagetoolbox.core.resources.icons.FreeDraw
|
||||
import com.t8rin.imagetoolbox.core.resources.icons.Lasso
|
||||
import com.t8rin.imagetoolbox.core.resources.icons.Line
|
||||
import com.t8rin.imagetoolbox.core.resources.icons.LineArrow
|
||||
import com.t8rin.imagetoolbox.core.resources.icons.LineDoubleArrow
|
||||
import com.t8rin.imagetoolbox.core.resources.icons.Polygon
|
||||
import com.t8rin.imagetoolbox.core.resources.icons.Spray
|
||||
import com.t8rin.imagetoolbox.core.resources.icons.Square
|
||||
import com.t8rin.imagetoolbox.core.resources.icons.Star
|
||||
import com.t8rin.imagetoolbox.core.resources.icons.Triangle
|
||||
import com.t8rin.imagetoolbox.feature.draw.domain.DrawPathMode
|
||||
|
||||
internal fun DrawPathMode.saveState(
|
||||
value: DrawPathMode
|
||||
): DrawPathMode = when (value) {
|
||||
is DrawPathMode.Rect if this is DrawPathMode.OutlinedRect -> {
|
||||
copy(
|
||||
rotationDegrees = value.rotationDegrees,
|
||||
cornerRadius = value.cornerRadius
|
||||
)
|
||||
}
|
||||
|
||||
is DrawPathMode.OutlinedRect if this is DrawPathMode.Rect -> {
|
||||
copy(
|
||||
rotationDegrees = value.rotationDegrees,
|
||||
cornerRadius = value.cornerRadius
|
||||
)
|
||||
}
|
||||
|
||||
is DrawPathMode.Polygon if this is DrawPathMode.OutlinedPolygon -> {
|
||||
copy(
|
||||
vertices = value.vertices,
|
||||
rotationDegrees = value.rotationDegrees,
|
||||
isRegular = value.isRegular
|
||||
)
|
||||
}
|
||||
|
||||
is DrawPathMode.OutlinedPolygon if this is DrawPathMode.Polygon -> {
|
||||
copy(
|
||||
vertices = value.vertices,
|
||||
rotationDegrees = value.rotationDegrees,
|
||||
isRegular = value.isRegular
|
||||
)
|
||||
}
|
||||
|
||||
is DrawPathMode.Star if this is DrawPathMode.OutlinedStar -> {
|
||||
copy(
|
||||
vertices = value.vertices,
|
||||
innerRadiusRatio = innerRadiusRatio,
|
||||
rotationDegrees = value.rotationDegrees,
|
||||
isRegular = value.isRegular
|
||||
)
|
||||
}
|
||||
|
||||
is DrawPathMode.OutlinedStar if this is DrawPathMode.Star -> {
|
||||
copy(
|
||||
vertices = value.vertices,
|
||||
innerRadiusRatio = innerRadiusRatio,
|
||||
rotationDegrees = value.rotationDegrees,
|
||||
isRegular = value.isRegular
|
||||
)
|
||||
}
|
||||
|
||||
is DrawPathMode.PointingArrow if this is DrawPathMode.LinePointingArrow -> {
|
||||
copy(
|
||||
sizeScale = value.sizeScale,
|
||||
angle = value.angle
|
||||
)
|
||||
}
|
||||
|
||||
is DrawPathMode.LinePointingArrow if this is DrawPathMode.PointingArrow -> {
|
||||
copy(
|
||||
sizeScale = value.sizeScale,
|
||||
angle = value.angle
|
||||
)
|
||||
}
|
||||
|
||||
is DrawPathMode.PointingArrow if this is DrawPathMode.DoublePointingArrow -> {
|
||||
copy(
|
||||
sizeScale = value.sizeScale,
|
||||
angle = value.angle
|
||||
)
|
||||
}
|
||||
|
||||
is DrawPathMode.DoublePointingArrow if this is DrawPathMode.PointingArrow -> {
|
||||
copy(
|
||||
sizeScale = value.sizeScale,
|
||||
angle = value.angle
|
||||
)
|
||||
}
|
||||
|
||||
is DrawPathMode.PointingArrow if this is DrawPathMode.DoubleLinePointingArrow -> {
|
||||
copy(
|
||||
sizeScale = value.sizeScale,
|
||||
angle = value.angle
|
||||
)
|
||||
}
|
||||
|
||||
is DrawPathMode.DoubleLinePointingArrow if this is DrawPathMode.PointingArrow -> {
|
||||
copy(
|
||||
sizeScale = value.sizeScale,
|
||||
angle = value.angle
|
||||
)
|
||||
}
|
||||
|
||||
is DrawPathMode.LinePointingArrow if this is DrawPathMode.DoublePointingArrow -> {
|
||||
copy(
|
||||
sizeScale = value.sizeScale,
|
||||
angle = value.angle
|
||||
)
|
||||
}
|
||||
|
||||
is DrawPathMode.DoublePointingArrow if this is DrawPathMode.LinePointingArrow -> {
|
||||
copy(
|
||||
sizeScale = value.sizeScale,
|
||||
angle = value.angle
|
||||
)
|
||||
}
|
||||
|
||||
is DrawPathMode.LinePointingArrow if this is DrawPathMode.DoubleLinePointingArrow -> {
|
||||
copy(
|
||||
sizeScale = value.sizeScale,
|
||||
angle = value.angle
|
||||
)
|
||||
}
|
||||
|
||||
is DrawPathMode.DoubleLinePointingArrow if this is DrawPathMode.LinePointingArrow -> {
|
||||
copy(
|
||||
sizeScale = value.sizeScale,
|
||||
angle = value.angle
|
||||
)
|
||||
}
|
||||
|
||||
is DrawPathMode.DoublePointingArrow if this is DrawPathMode.DoubleLinePointingArrow -> {
|
||||
copy(
|
||||
sizeScale = value.sizeScale,
|
||||
angle = value.angle
|
||||
)
|
||||
}
|
||||
|
||||
is DrawPathMode.DoubleLinePointingArrow if this is DrawPathMode.DoublePointingArrow -> {
|
||||
copy(
|
||||
sizeScale = value.sizeScale,
|
||||
angle = value.angle
|
||||
)
|
||||
}
|
||||
|
||||
is DrawPathMode.FloodFill if this is DrawPathMode.FloodFill -> {
|
||||
copy(
|
||||
tolerance = value.tolerance
|
||||
)
|
||||
}
|
||||
|
||||
is DrawPathMode.Spray if this is DrawPathMode.Spray -> {
|
||||
copy(
|
||||
density = value.density,
|
||||
pixelSize = value.pixelSize,
|
||||
isSquareShaped = value.isSquareShaped
|
||||
)
|
||||
}
|
||||
|
||||
else -> this
|
||||
}.run {
|
||||
if (value is DrawPathMode.Outlined && this is DrawPathMode.Outlined) {
|
||||
updateOutlined(
|
||||
fillColor = value.fillColor?.toColor()
|
||||
)
|
||||
} else this
|
||||
}
|
||||
|
||||
internal fun DrawPathMode.density(): Int = when (this) {
|
||||
is DrawPathMode.Spray -> density
|
||||
else -> 0
|
||||
}
|
||||
|
||||
internal fun DrawPathMode.pixelSize(): Float = when (this) {
|
||||
is DrawPathMode.Spray -> pixelSize
|
||||
else -> 0f
|
||||
}
|
||||
|
||||
internal fun DrawPathMode.isSquareShaped(): Boolean = when (this) {
|
||||
is DrawPathMode.Spray -> isSquareShaped
|
||||
else -> false
|
||||
}
|
||||
|
||||
internal fun DrawPathMode.tolerance(): Float = when (this) {
|
||||
is DrawPathMode.FloodFill -> tolerance
|
||||
else -> 0f
|
||||
}
|
||||
|
||||
internal fun DrawPathMode.sizeScale(): Float = when (this) {
|
||||
is DrawPathMode.PointingArrow -> sizeScale
|
||||
is DrawPathMode.LinePointingArrow -> sizeScale
|
||||
is DrawPathMode.DoublePointingArrow -> sizeScale
|
||||
is DrawPathMode.DoubleLinePointingArrow -> sizeScale
|
||||
else -> 1f
|
||||
}
|
||||
|
||||
internal fun DrawPathMode.angle(): Float = when (this) {
|
||||
is DrawPathMode.PointingArrow -> angle
|
||||
is DrawPathMode.LinePointingArrow -> angle
|
||||
is DrawPathMode.DoublePointingArrow -> angle
|
||||
is DrawPathMode.DoubleLinePointingArrow -> angle
|
||||
else -> 0f
|
||||
}
|
||||
|
||||
internal fun DrawPathMode.vertices(): Int = when (this) {
|
||||
is DrawPathMode.Polygon -> vertices
|
||||
is DrawPathMode.OutlinedPolygon -> vertices
|
||||
is DrawPathMode.Star -> vertices
|
||||
is DrawPathMode.OutlinedStar -> vertices
|
||||
else -> 0
|
||||
}
|
||||
|
||||
internal fun DrawPathMode.rotationDegrees(): Int = when (this) {
|
||||
is DrawPathMode.Polygon -> rotationDegrees
|
||||
is DrawPathMode.OutlinedPolygon -> rotationDegrees
|
||||
is DrawPathMode.Star -> rotationDegrees
|
||||
is DrawPathMode.OutlinedStar -> rotationDegrees
|
||||
is DrawPathMode.Rect -> rotationDegrees
|
||||
is DrawPathMode.OutlinedRect -> rotationDegrees
|
||||
else -> 0
|
||||
}
|
||||
|
||||
internal fun DrawPathMode.cornerRadius(): Float = when (this) {
|
||||
is DrawPathMode.Rect -> cornerRadius
|
||||
is DrawPathMode.OutlinedRect -> cornerRadius
|
||||
else -> 0f
|
||||
}
|
||||
|
||||
internal fun DrawPathMode.isRegular(): Boolean = when (this) {
|
||||
is DrawPathMode.Polygon -> isRegular
|
||||
is DrawPathMode.OutlinedPolygon -> isRegular
|
||||
is DrawPathMode.Star -> isRegular
|
||||
is DrawPathMode.OutlinedStar -> isRegular
|
||||
else -> false
|
||||
}
|
||||
|
||||
internal fun DrawPathMode.innerRadiusRatio(): Float = when (this) {
|
||||
is DrawPathMode.Star -> innerRadiusRatio
|
||||
is DrawPathMode.OutlinedStar -> innerRadiusRatio
|
||||
else -> 0.5f
|
||||
}
|
||||
|
||||
internal fun DrawPathMode.updateOutlined(
|
||||
fillColor: Color?
|
||||
) = when (this) {
|
||||
is DrawPathMode.Outlined -> {
|
||||
when (this) {
|
||||
is DrawPathMode.OutlinedOval -> copy(
|
||||
fillColor = fillColor?.toModel()
|
||||
)
|
||||
|
||||
is DrawPathMode.OutlinedPolygon -> copy(
|
||||
fillColor = fillColor?.toModel()
|
||||
)
|
||||
|
||||
is DrawPathMode.OutlinedRect -> copy(
|
||||
fillColor = fillColor?.toModel()
|
||||
)
|
||||
|
||||
is DrawPathMode.OutlinedStar -> copy(
|
||||
fillColor = fillColor?.toModel()
|
||||
)
|
||||
|
||||
is DrawPathMode.OutlinedTriangle -> copy(
|
||||
fillColor = fillColor?.toModel()
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
else -> this
|
||||
}
|
||||
|
||||
internal fun DrawPathMode.updatePolygon(
|
||||
vertices: Int? = null,
|
||||
rotationDegrees: Int? = null,
|
||||
isRegular: Boolean? = null
|
||||
) = when (this) {
|
||||
is DrawPathMode.Polygon -> {
|
||||
copy(
|
||||
vertices = vertices ?: this.vertices,
|
||||
rotationDegrees = rotationDegrees ?: this.rotationDegrees,
|
||||
isRegular = isRegular ?: this.isRegular
|
||||
)
|
||||
}
|
||||
|
||||
is DrawPathMode.OutlinedPolygon -> {
|
||||
copy(
|
||||
vertices = vertices ?: this.vertices,
|
||||
rotationDegrees = rotationDegrees ?: this.rotationDegrees,
|
||||
isRegular = isRegular ?: this.isRegular
|
||||
)
|
||||
}
|
||||
|
||||
else -> this
|
||||
}
|
||||
|
||||
internal fun DrawPathMode.updateStar(
|
||||
vertices: Int? = null,
|
||||
innerRadiusRatio: Float? = null,
|
||||
rotationDegrees: Int? = null,
|
||||
isRegular: Boolean? = null
|
||||
) = when (this) {
|
||||
is DrawPathMode.Star -> {
|
||||
copy(
|
||||
vertices = vertices ?: this.vertices,
|
||||
innerRadiusRatio = innerRadiusRatio ?: this.innerRadiusRatio,
|
||||
rotationDegrees = rotationDegrees ?: this.rotationDegrees,
|
||||
isRegular = isRegular ?: this.isRegular
|
||||
)
|
||||
}
|
||||
|
||||
is DrawPathMode.OutlinedStar -> {
|
||||
copy(
|
||||
vertices = vertices ?: this.vertices,
|
||||
innerRadiusRatio = innerRadiusRatio ?: this.innerRadiusRatio,
|
||||
rotationDegrees = rotationDegrees ?: this.rotationDegrees,
|
||||
isRegular = isRegular ?: this.isRegular
|
||||
)
|
||||
}
|
||||
|
||||
else -> this
|
||||
}
|
||||
|
||||
internal fun DrawPathMode.updateRect(
|
||||
rotationDegrees: Int? = null,
|
||||
cornerRadius: Float? = null
|
||||
) = when (this) {
|
||||
is DrawPathMode.Rect -> {
|
||||
copy(
|
||||
rotationDegrees = rotationDegrees ?: this.rotationDegrees,
|
||||
cornerRadius = cornerRadius ?: this.cornerRadius
|
||||
)
|
||||
}
|
||||
|
||||
is DrawPathMode.OutlinedRect -> {
|
||||
copy(
|
||||
rotationDegrees = rotationDegrees ?: this.rotationDegrees,
|
||||
cornerRadius = cornerRadius ?: this.cornerRadius
|
||||
)
|
||||
}
|
||||
|
||||
else -> this
|
||||
}
|
||||
|
||||
internal fun DrawPathMode.updateArrow(
|
||||
sizeScale: Float? = null,
|
||||
angle: Float? = null
|
||||
) = when (this) {
|
||||
is DrawPathMode.PointingArrow -> {
|
||||
copy(
|
||||
sizeScale = sizeScale ?: this.sizeScale,
|
||||
angle = angle ?: this.angle
|
||||
)
|
||||
}
|
||||
|
||||
is DrawPathMode.LinePointingArrow -> {
|
||||
copy(
|
||||
sizeScale = sizeScale ?: this.sizeScale,
|
||||
angle = angle ?: this.angle
|
||||
)
|
||||
}
|
||||
|
||||
is DrawPathMode.DoublePointingArrow -> {
|
||||
copy(
|
||||
sizeScale = sizeScale ?: this.sizeScale,
|
||||
angle = angle ?: this.angle
|
||||
)
|
||||
}
|
||||
|
||||
is DrawPathMode.DoubleLinePointingArrow -> {
|
||||
copy(
|
||||
sizeScale = sizeScale ?: this.sizeScale,
|
||||
angle = angle ?: this.angle
|
||||
)
|
||||
}
|
||||
|
||||
else -> this
|
||||
}
|
||||
|
||||
internal fun DrawPathMode.updateFloodFill(
|
||||
tolerance: Float? = null,
|
||||
) = when (this) {
|
||||
is DrawPathMode.FloodFill -> {
|
||||
copy(
|
||||
tolerance = tolerance ?: this.tolerance
|
||||
)
|
||||
}
|
||||
|
||||
else -> this
|
||||
}
|
||||
|
||||
internal fun DrawPathMode.updateSpray(
|
||||
density: Int? = null,
|
||||
pixelSize: Float? = null,
|
||||
isSquareShaped: Boolean? = null,
|
||||
) = when (this) {
|
||||
is DrawPathMode.Spray -> {
|
||||
copy(
|
||||
density = density ?: this.density,
|
||||
pixelSize = pixelSize ?: this.pixelSize,
|
||||
isSquareShaped = isSquareShaped ?: this.isSquareShaped
|
||||
)
|
||||
}
|
||||
|
||||
else -> this
|
||||
}
|
||||
|
||||
internal fun DrawPathMode.isArrow(): Boolean =
|
||||
this is DrawPathMode.PointingArrow || this is DrawPathMode.LinePointingArrow
|
||||
|| this is DrawPathMode.DoublePointingArrow || this is DrawPathMode.DoubleLinePointingArrow
|
||||
|
||||
internal fun DrawPathMode.isRect(): Boolean =
|
||||
this is DrawPathMode.Rect || this is DrawPathMode.OutlinedRect
|
||||
|
||||
internal fun DrawPathMode.isPolygon(): Boolean =
|
||||
this is DrawPathMode.Polygon || this is DrawPathMode.OutlinedPolygon
|
||||
|
||||
internal fun DrawPathMode.isStar(): Boolean =
|
||||
this is DrawPathMode.Star || this is DrawPathMode.OutlinedStar
|
||||
|
||||
internal fun DrawPathMode.isFloodFill(): Boolean =
|
||||
this is DrawPathMode.FloodFill
|
||||
|
||||
internal fun DrawPathMode.isSpray(): Boolean =
|
||||
this is DrawPathMode.Spray
|
||||
|
||||
internal fun DrawPathMode.getSubtitle(): Int = when (this) {
|
||||
is DrawPathMode.DoubleLinePointingArrow -> R.string.double_line_arrow_sub
|
||||
is DrawPathMode.DoublePointingArrow -> R.string.double_arrow_sub
|
||||
DrawPathMode.Free -> R.string.free_drawing_sub
|
||||
DrawPathMode.Line -> R.string.line_sub
|
||||
is DrawPathMode.LinePointingArrow -> R.string.line_arrow_sub
|
||||
is DrawPathMode.PointingArrow -> R.string.arrow_sub
|
||||
is DrawPathMode.OutlinedOval -> R.string.outlined_oval_sub
|
||||
is DrawPathMode.OutlinedRect -> R.string.outlined_rect_sub
|
||||
DrawPathMode.Oval -> R.string.oval_sub
|
||||
is DrawPathMode.Rect -> R.string.rect_sub
|
||||
DrawPathMode.Lasso -> R.string.lasso_sub
|
||||
is DrawPathMode.OutlinedTriangle -> R.string.outlined_triangle_sub
|
||||
DrawPathMode.Triangle -> R.string.triangle_sub
|
||||
is DrawPathMode.Polygon -> R.string.polygon_sub
|
||||
is DrawPathMode.OutlinedPolygon -> R.string.outlined_polygon_sub
|
||||
is DrawPathMode.OutlinedStar -> R.string.outlined_star_sub
|
||||
is DrawPathMode.Star -> R.string.star_sub
|
||||
is DrawPathMode.FloodFill -> R.string.flood_fill_sub
|
||||
is DrawPathMode.Spray -> R.string.spray_sub
|
||||
}
|
||||
|
||||
internal fun DrawPathMode.getTitle(): Int = when (this) {
|
||||
is DrawPathMode.DoubleLinePointingArrow -> R.string.double_line_arrow
|
||||
is DrawPathMode.DoublePointingArrow -> R.string.double_arrow
|
||||
DrawPathMode.Free -> R.string.free_drawing
|
||||
DrawPathMode.Line -> R.string.line
|
||||
is DrawPathMode.LinePointingArrow -> R.string.line_arrow
|
||||
is DrawPathMode.PointingArrow -> R.string.arrow
|
||||
is DrawPathMode.OutlinedOval -> R.string.outlined_oval
|
||||
is DrawPathMode.OutlinedRect -> R.string.outlined_rect
|
||||
DrawPathMode.Oval -> R.string.oval
|
||||
is DrawPathMode.Rect -> R.string.rect
|
||||
DrawPathMode.Lasso -> R.string.lasso
|
||||
is DrawPathMode.OutlinedTriangle -> R.string.outlined_triangle
|
||||
DrawPathMode.Triangle -> R.string.triangle
|
||||
is DrawPathMode.Polygon -> R.string.polygon
|
||||
is DrawPathMode.OutlinedPolygon -> R.string.outlined_polygon
|
||||
is DrawPathMode.OutlinedStar -> R.string.outlined_star
|
||||
is DrawPathMode.Star -> R.string.star
|
||||
is DrawPathMode.FloodFill -> R.string.flood_fill
|
||||
is DrawPathMode.Spray -> R.string.spray
|
||||
}
|
||||
|
||||
internal fun DrawPathMode.getIcon(): ImageVector = when (this) {
|
||||
is DrawPathMode.DoubleLinePointingArrow -> Icons.Rounded.LineDoubleArrow
|
||||
is DrawPathMode.DoublePointingArrow -> Icons.Rounded.FreeDoubleArrow
|
||||
DrawPathMode.Free -> Icons.Rounded.FreeDraw
|
||||
DrawPathMode.Line -> Icons.Rounded.Line
|
||||
is DrawPathMode.LinePointingArrow -> Icons.Rounded.LineArrow
|
||||
is DrawPathMode.PointingArrow -> Icons.Rounded.FreeArrow
|
||||
is DrawPathMode.OutlinedOval -> Icons.Outlined.Circle
|
||||
is DrawPathMode.OutlinedRect -> Icons.Rounded.CheckBoxOutlineBlank
|
||||
DrawPathMode.Oval -> Icons.Rounded.Circle
|
||||
is DrawPathMode.Rect -> Icons.Rounded.Square
|
||||
DrawPathMode.Lasso -> Icons.Rounded.Lasso
|
||||
DrawPathMode.Triangle -> Icons.Rounded.Triangle
|
||||
is DrawPathMode.OutlinedTriangle -> Icons.Outlined.Triangle
|
||||
is DrawPathMode.Polygon -> Icons.Rounded.Polygon
|
||||
is DrawPathMode.OutlinedPolygon -> Icons.Outlined.Polygon
|
||||
is DrawPathMode.OutlinedStar -> Icons.Outlined.Star
|
||||
is DrawPathMode.Star -> Icons.Rounded.Star
|
||||
is DrawPathMode.FloodFill -> Icons.Rounded.FloodFill
|
||||
is DrawPathMode.Spray -> Icons.Outlined.Spray
|
||||
}
|
||||
+96
@@ -0,0 +1,96 @@
|
||||
/*
|
||||
* 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.draw.presentation.components.utils
|
||||
|
||||
import android.graphics.Bitmap
|
||||
import android.graphics.Canvas
|
||||
import android.graphics.Matrix
|
||||
import android.graphics.Paint
|
||||
import android.graphics.Path
|
||||
import android.graphics.PathMeasure
|
||||
import androidx.core.graphics.withSave
|
||||
import kotlin.math.atan2
|
||||
|
||||
fun Canvas.drawRepeatedTextOnPath(
|
||||
text: String,
|
||||
path: Path,
|
||||
paint: Paint,
|
||||
interval: Float = 0f
|
||||
) {
|
||||
val pathMeasure = PathMeasure(path, false)
|
||||
val pathLength = pathMeasure.length
|
||||
|
||||
val textWidth = paint.measureText(text)
|
||||
|
||||
val fullRepeats = (pathLength / (textWidth + interval)).toInt()
|
||||
|
||||
val remainingLength = pathLength - fullRepeats * (textWidth + interval)
|
||||
|
||||
var distance = 0f
|
||||
|
||||
repeat(fullRepeats) {
|
||||
drawTextOnPath(text, path, distance, 0f, paint)
|
||||
distance += (textWidth + interval)
|
||||
}
|
||||
|
||||
if (remainingLength > 0f) {
|
||||
val ratio = (textWidth + interval - (remainingLength)) / (textWidth + interval)
|
||||
val endOffset = (text.length - (text.length * ratio).toInt()).coerceAtLeast(0)
|
||||
drawTextOnPath(text.substring(0, endOffset), path, distance, 0f, paint)
|
||||
}
|
||||
}
|
||||
|
||||
fun Canvas.drawRepeatedBitmapOnPath(
|
||||
bitmap: Bitmap,
|
||||
path: Path,
|
||||
paint: Paint,
|
||||
interval: Float = 0f
|
||||
) {
|
||||
val pathMeasure = PathMeasure(path, false)
|
||||
val pathLength = pathMeasure.length
|
||||
|
||||
val bitmapWidth = bitmap.width.toFloat()
|
||||
val bitmapHeight = bitmap.height.toFloat()
|
||||
|
||||
var distance = 0f
|
||||
val matrix = Matrix()
|
||||
|
||||
while (distance < pathLength) {
|
||||
val pos = FloatArray(2)
|
||||
val tan = FloatArray(2)
|
||||
pathMeasure.getPosTan(distance, pos, tan)
|
||||
|
||||
val degree = Math.toDegrees(atan2(tan[1].toDouble(), tan[0].toDouble())).toFloat()
|
||||
|
||||
withSave {
|
||||
translate(pos[0], pos[1])
|
||||
rotate(degree)
|
||||
|
||||
matrix.reset()
|
||||
matrix.postTranslate(-bitmapWidth / 2, -bitmapHeight / 2)
|
||||
matrix.postRotate(degree)
|
||||
matrix.postTranslate(bitmapWidth / 2, bitmapHeight / 2)
|
||||
drawBitmap(bitmap, matrix, paint)
|
||||
}
|
||||
|
||||
if (interval < 0 && distance + bitmapWidth < 0) break
|
||||
else {
|
||||
distance += (bitmapWidth + interval)
|
||||
}
|
||||
}
|
||||
}
|
||||
+521
@@ -0,0 +1,521 @@
|
||||
/*
|
||||
* 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.draw.presentation.components.utils
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
import android.content.Context
|
||||
import android.graphics.Bitmap
|
||||
import android.graphics.BlurMaskFilter
|
||||
import android.graphics.Canvas
|
||||
import android.graphics.Matrix
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.State
|
||||
import androidx.compose.runtime.derivedStateOf
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.geometry.Offset
|
||||
import androidx.compose.ui.geometry.Size
|
||||
import androidx.compose.ui.graphics.BlendMode
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.ImageBitmap
|
||||
import androidx.compose.ui.graphics.Paint
|
||||
import androidx.compose.ui.graphics.PaintingStyle
|
||||
import androidx.compose.ui.graphics.Path
|
||||
import androidx.compose.ui.graphics.PathEffect
|
||||
import androidx.compose.ui.graphics.Shape
|
||||
import androidx.compose.ui.graphics.StampedPathEffectStyle
|
||||
import androidx.compose.ui.graphics.StrokeCap
|
||||
import androidx.compose.ui.graphics.StrokeJoin
|
||||
import androidx.compose.ui.graphics.addOutline
|
||||
import androidx.compose.ui.graphics.asAndroidBitmap
|
||||
import androidx.compose.ui.graphics.asAndroidPath
|
||||
import androidx.compose.ui.graphics.asComposePath
|
||||
import androidx.compose.ui.graphics.asImageBitmap
|
||||
import androidx.compose.ui.graphics.nativePaint
|
||||
import androidx.compose.ui.graphics.toArgb
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.unit.LayoutDirection
|
||||
import androidx.core.graphics.applyCanvas
|
||||
import androidx.core.graphics.createBitmap
|
||||
import coil3.imageLoader
|
||||
import coil3.request.ImageRequest
|
||||
import coil3.toBitmap
|
||||
import com.t8rin.imagetoolbox.core.data.image.utils.drawBitmap
|
||||
import com.t8rin.imagetoolbox.core.data.utils.safeConfig
|
||||
import com.t8rin.imagetoolbox.core.domain.model.IntegerSize
|
||||
import com.t8rin.imagetoolbox.core.domain.model.Pt
|
||||
import com.t8rin.imagetoolbox.core.domain.model.max
|
||||
import com.t8rin.imagetoolbox.core.filters.domain.model.Filter
|
||||
import com.t8rin.imagetoolbox.core.filters.presentation.model.UiNativeStackBlurFilter
|
||||
import com.t8rin.imagetoolbox.core.filters.presentation.model.UiPixelationFilter
|
||||
import com.t8rin.imagetoolbox.core.filters.presentation.model.toUiFilter
|
||||
import com.t8rin.imagetoolbox.core.resources.shapes.MaterialStarShape
|
||||
import com.t8rin.imagetoolbox.core.ui.utils.helper.ContextUtils.density
|
||||
import com.t8rin.imagetoolbox.core.ui.widget.modifier.Line
|
||||
import com.t8rin.imagetoolbox.core.utils.appContext
|
||||
import com.t8rin.imagetoolbox.core.utils.toTypeface
|
||||
import com.t8rin.imagetoolbox.feature.draw.domain.DrawLineStyle
|
||||
import com.t8rin.imagetoolbox.feature.draw.domain.DrawMode
|
||||
import com.t8rin.imagetoolbox.feature.draw.domain.DrawPathMode
|
||||
import kotlin.math.roundToInt
|
||||
import kotlin.math.sqrt
|
||||
import android.graphics.Canvas as NativeCanvas
|
||||
import android.graphics.Paint as NativePaint
|
||||
import android.graphics.Path as NativePath
|
||||
|
||||
/**
|
||||
* Needed to trigger recomposition
|
||||
**/
|
||||
fun ImageBitmap.copy(): ImageBitmap = asAndroidBitmap().asImageBitmap()
|
||||
|
||||
internal fun Path.copy(): Path = copyAsAndroidPath().asComposePath()
|
||||
|
||||
internal fun Path.copyAsAndroidPath(): NativePath = NativePath(this.asAndroidPath())
|
||||
|
||||
internal fun NativePath.mirror(
|
||||
x: Float,
|
||||
y: Float,
|
||||
x1: Float,
|
||||
y1: Float
|
||||
): NativePath {
|
||||
val dx = x1 - x
|
||||
val dy = y1 - y
|
||||
val lengthSq = dx * dx + dy * dy
|
||||
|
||||
val matrix = Matrix().apply {
|
||||
setValues(
|
||||
floatArrayOf(
|
||||
(dx * dx - dy * dy) / lengthSq,
|
||||
(2 * dx * dy) / lengthSq,
|
||||
(2 * x * dy * dy - 2 * y * dx * dy) / lengthSq,
|
||||
(2 * dx * dy) / lengthSq,
|
||||
(dy * dy - dx * dx) / lengthSq,
|
||||
(2 * y * dx * dx - 2 * x * dx * dy) / lengthSq,
|
||||
0f,
|
||||
0f,
|
||||
1f
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
val mirroredPath = NativePath()
|
||||
this.transform(matrix, mirroredPath)
|
||||
return mirroredPath
|
||||
}
|
||||
|
||||
@Suppress("unused")
|
||||
internal fun Path.mirrorIfNeeded(
|
||||
canvasSize: IntegerSize,
|
||||
mirroringLines: List<Line>
|
||||
): Path = asAndroidPath().mirrorIfNeeded(
|
||||
canvasSize = canvasSize,
|
||||
mirroringLines = mirroringLines
|
||||
).asComposePath()
|
||||
|
||||
internal fun NativePath.mirrorIfNeeded(
|
||||
canvasSize: IntegerSize,
|
||||
mirroringLines: List<Line>
|
||||
): NativePath = if (mirroringLines.isNotEmpty()) {
|
||||
NativePath(this).apply {
|
||||
mirroringLines.forEach { mirroringLine ->
|
||||
addPath(
|
||||
mirror(
|
||||
x = mirroringLine.startX * canvasSize.width,
|
||||
y = mirroringLine.startY * canvasSize.height,
|
||||
x1 = mirroringLine.endX * canvasSize.width,
|
||||
y1 = mirroringLine.endY * canvasSize.height
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
this
|
||||
}
|
||||
|
||||
@Suppress("unused")
|
||||
internal fun Path.mirror(
|
||||
x: Float,
|
||||
y: Float,
|
||||
x1: Float,
|
||||
y1: Float
|
||||
): Path = asAndroidPath().mirror(
|
||||
x = x,
|
||||
y = y,
|
||||
x1 = x1,
|
||||
y1 = y1
|
||||
).asComposePath()
|
||||
|
||||
@Suppress("unused")
|
||||
fun Canvas.drawInfiniteLine(
|
||||
line: Line,
|
||||
paint: NativePaint = NativePaint().apply {
|
||||
color = Color.Red.toArgb()
|
||||
style = NativePaint.Style.STROKE
|
||||
strokeWidth = 5f
|
||||
}
|
||||
) {
|
||||
val width = width.toFloat()
|
||||
val height = height.toFloat()
|
||||
|
||||
val startX = line.startX * width
|
||||
val startY = line.startY * height
|
||||
val endX = line.endX * width
|
||||
val endY = line.endY * height
|
||||
|
||||
val dx = endX - startX
|
||||
val dy = endY - startY
|
||||
|
||||
if (dx == 0f) {
|
||||
drawLine(startX, 0f, startX, height, paint)
|
||||
return
|
||||
}
|
||||
|
||||
if (dy == 0f) {
|
||||
drawLine(0f, startY, width, startY, paint)
|
||||
return
|
||||
}
|
||||
|
||||
val directionX = dx / sqrt(dx * dx + dy * dy)
|
||||
val directionY = dy / sqrt(dx * dx + dy * dy)
|
||||
|
||||
val scale = maxOf(width, height) * 2
|
||||
val extendedStartX = startX - directionX * scale
|
||||
val extendedStartY = startY - directionY * scale
|
||||
val extendedEndX = endX + directionX * scale
|
||||
val extendedEndY = endY + directionY * scale
|
||||
|
||||
drawLine(extendedStartX, extendedStartY, extendedEndX, extendedEndY, paint)
|
||||
}
|
||||
|
||||
internal fun ImageBitmap.clipBitmap(
|
||||
path: Path,
|
||||
paint: Paint,
|
||||
): ImageBitmap = asAndroidBitmap()
|
||||
.let { it.copy(it.safeConfig, true) }
|
||||
.applyCanvas {
|
||||
drawPath(
|
||||
NativePath(path.asAndroidPath()).apply {
|
||||
fillType = NativePath.FillType.INVERSE_WINDING
|
||||
},
|
||||
paint.nativePaint
|
||||
)
|
||||
}.asImageBitmap()
|
||||
|
||||
internal fun ImageBitmap.overlay(overlay: ImageBitmap): ImageBitmap {
|
||||
val image = this.asAndroidBitmap()
|
||||
return createBitmap(
|
||||
width = image.width,
|
||||
height = image.height,
|
||||
config = image.safeConfig
|
||||
).applyCanvas {
|
||||
drawBitmap(image)
|
||||
drawBitmap(overlay.asAndroidBitmap())
|
||||
}.asImageBitmap()
|
||||
}
|
||||
|
||||
@Composable
|
||||
internal fun rememberPaint(
|
||||
strokeWidth: Pt,
|
||||
isEraserOn: Boolean,
|
||||
drawColor: Color,
|
||||
brushSoftness: Pt,
|
||||
drawMode: DrawMode,
|
||||
canvasSize: IntegerSize,
|
||||
drawPathMode: DrawPathMode,
|
||||
drawLineStyle: DrawLineStyle
|
||||
): State<NativePaint> {
|
||||
val context = LocalContext.current
|
||||
|
||||
return remember(
|
||||
strokeWidth,
|
||||
isEraserOn,
|
||||
drawColor,
|
||||
brushSoftness,
|
||||
drawMode,
|
||||
canvasSize,
|
||||
drawPathMode,
|
||||
context,
|
||||
drawLineStyle
|
||||
) {
|
||||
derivedStateOf {
|
||||
val isSharpEdge = drawPathMode.isSharpEdge
|
||||
val isFilled = drawPathMode.isFilled
|
||||
|
||||
Paint().apply {
|
||||
if (drawMode !is DrawMode.Text && drawMode !is DrawMode.Image) {
|
||||
pathEffect = drawLineStyle.asPathEffect(
|
||||
canvasSize = canvasSize,
|
||||
strokeWidth = strokeWidth.toPx(canvasSize),
|
||||
context = context
|
||||
)
|
||||
}
|
||||
blendMode = if (!isEraserOn) blendMode else BlendMode.Clear
|
||||
if (isEraserOn) {
|
||||
style = PaintingStyle.Stroke
|
||||
this.strokeWidth = strokeWidth.toPx(canvasSize)
|
||||
strokeCap = StrokeCap.Round
|
||||
strokeJoin = StrokeJoin.Round
|
||||
} else {
|
||||
if (drawMode !is DrawMode.Text) {
|
||||
if (isFilled) {
|
||||
style = PaintingStyle.Fill
|
||||
} else {
|
||||
style = PaintingStyle.Stroke
|
||||
this.strokeWidth = drawPathMode.convertStrokeWidth(
|
||||
strokeWidth = strokeWidth,
|
||||
canvasSize = canvasSize
|
||||
)
|
||||
if (drawMode is DrawMode.Highlighter || isSharpEdge) {
|
||||
strokeCap = StrokeCap.Square
|
||||
} else {
|
||||
strokeCap = StrokeCap.Round
|
||||
strokeJoin = StrokeJoin.Round
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
color = if (drawMode is DrawMode.PathEffect) {
|
||||
Color.Transparent
|
||||
} else drawColor
|
||||
alpha = drawColor.alpha
|
||||
}.nativePaint.apply {
|
||||
if (drawMode is DrawMode.Neon && !isEraserOn) {
|
||||
this.color = Color.White.toArgb()
|
||||
setShadowLayer(
|
||||
brushSoftness.toPx(canvasSize),
|
||||
0f,
|
||||
0f,
|
||||
drawColor
|
||||
.copy(alpha = .8f)
|
||||
.toArgb()
|
||||
)
|
||||
} else if (brushSoftness.value > 0f) {
|
||||
maskFilter = BlurMaskFilter(
|
||||
brushSoftness.toPx(canvasSize),
|
||||
BlurMaskFilter.Blur.NORMAL
|
||||
)
|
||||
}
|
||||
if (drawMode is DrawMode.Text && !isEraserOn) {
|
||||
isAntiAlias = true
|
||||
textSize = strokeWidth.toPx(canvasSize)
|
||||
typeface = drawMode.font.toTypeface()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun pathEffectPaint(
|
||||
strokeWidth: Pt,
|
||||
drawPathMode: DrawPathMode,
|
||||
canvasSize: IntegerSize,
|
||||
): NativePaint {
|
||||
val isSharpEdge = drawPathMode.isSharpEdge
|
||||
val isFilled = drawPathMode.isFilled
|
||||
|
||||
return Paint().apply {
|
||||
if (isFilled) {
|
||||
style = PaintingStyle.Fill
|
||||
} else {
|
||||
style = PaintingStyle.Stroke
|
||||
this.strokeWidth = strokeWidth.toPx(canvasSize)
|
||||
if (isSharpEdge) {
|
||||
strokeCap = StrokeCap.Square
|
||||
} else {
|
||||
strokeCap = StrokeCap.Round
|
||||
strokeJoin = StrokeJoin.Round
|
||||
}
|
||||
}
|
||||
|
||||
color = Color.Transparent
|
||||
blendMode = BlendMode.Clear
|
||||
}.nativePaint
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun rememberPathEffectPaint(
|
||||
strokeWidth: Pt,
|
||||
drawPathMode: DrawPathMode,
|
||||
canvasSize: IntegerSize,
|
||||
): State<NativePaint> = remember(
|
||||
strokeWidth,
|
||||
drawPathMode,
|
||||
canvasSize
|
||||
) {
|
||||
derivedStateOf {
|
||||
pathEffectPaint(
|
||||
strokeWidth = strokeWidth,
|
||||
drawPathMode = drawPathMode,
|
||||
canvasSize = canvasSize
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
internal fun DrawLineStyle.asPathEffect(
|
||||
canvasSize: IntegerSize,
|
||||
strokeWidth: Float,
|
||||
context: Context
|
||||
): PathEffect? = when (this) {
|
||||
is DrawLineStyle.Dashed -> {
|
||||
PathEffect.dashPathEffect(
|
||||
intervals = floatArrayOf(
|
||||
size.toPx(canvasSize),
|
||||
gap.toPx(canvasSize) + strokeWidth
|
||||
),
|
||||
phase = 0f
|
||||
)
|
||||
}
|
||||
|
||||
DrawLineStyle.DotDashed -> {
|
||||
val dashOnInterval1 = strokeWidth * 4
|
||||
val dashOffInterval1 = strokeWidth * 2
|
||||
val dashOnInterval2 = strokeWidth / 4
|
||||
val dashOffInterval2 = strokeWidth * 2
|
||||
|
||||
PathEffect.dashPathEffect(
|
||||
intervals = floatArrayOf(
|
||||
dashOnInterval1,
|
||||
dashOffInterval1,
|
||||
dashOnInterval2,
|
||||
dashOffInterval2
|
||||
),
|
||||
phase = 0f
|
||||
)
|
||||
}
|
||||
|
||||
is DrawLineStyle.Stamped<*> -> {
|
||||
fun Shape.toPath(): Path = Path().apply {
|
||||
addOutline(
|
||||
createOutline(
|
||||
size = Size(strokeWidth, strokeWidth),
|
||||
layoutDirection = LayoutDirection.Ltr,
|
||||
density = context.density
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
val path: Path? = when (shape) {
|
||||
is Shape -> shape.toPath()
|
||||
is NativePath -> shape.asComposePath()
|
||||
is Path -> shape
|
||||
null -> MaterialStarShape.toPath()
|
||||
else -> null
|
||||
}
|
||||
|
||||
path?.let {
|
||||
PathEffect.stampedPathEffect(
|
||||
shape = it,
|
||||
advance = spacing.toPx(canvasSize) + strokeWidth,
|
||||
phase = 0f,
|
||||
style = StampedPathEffectStyle.Morph
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
is DrawLineStyle.ZigZag -> {
|
||||
val zigZagPath = Path().apply {
|
||||
val zigZagLineWidth = strokeWidth / heightRatio
|
||||
val shapeVerticalOffset = (strokeWidth / 2) / 2
|
||||
val shapeHorizontalOffset = (strokeWidth / 2) / 2
|
||||
moveTo(0f, 0f)
|
||||
lineTo(strokeWidth / 2, strokeWidth / 2)
|
||||
lineTo(strokeWidth, 0f)
|
||||
lineTo(strokeWidth, 0f + zigZagLineWidth)
|
||||
lineTo(strokeWidth / 2, strokeWidth / 2 + zigZagLineWidth)
|
||||
lineTo(0f, 0f + zigZagLineWidth)
|
||||
translate(Offset(-shapeHorizontalOffset, -shapeVerticalOffset))
|
||||
}
|
||||
|
||||
PathEffect.stampedPathEffect(
|
||||
shape = zigZagPath,
|
||||
advance = strokeWidth,
|
||||
phase = 0f,
|
||||
style = StampedPathEffectStyle.Morph
|
||||
)
|
||||
}
|
||||
|
||||
DrawLineStyle.None -> null
|
||||
}
|
||||
|
||||
@SuppressLint("ComposableNaming")
|
||||
@Composable
|
||||
internal fun NativeCanvas.drawRepeatedImageOnPath(
|
||||
drawMode: DrawMode.Image,
|
||||
strokeWidth: Pt,
|
||||
canvasSize: IntegerSize,
|
||||
path: NativePath,
|
||||
paint: NativePaint,
|
||||
invalidations: Int
|
||||
) {
|
||||
var pathImage by remember(strokeWidth, canvasSize, drawMode.imageData) {
|
||||
mutableStateOf<Bitmap?>(null)
|
||||
}
|
||||
LaunchedEffect(pathImage, drawMode.imageData, strokeWidth, canvasSize, invalidations) {
|
||||
if (pathImage == null) {
|
||||
pathImage = appContext.imageLoader.execute(
|
||||
ImageRequest.Builder(appContext)
|
||||
.data(drawMode.imageData)
|
||||
.size(strokeWidth.toPx(canvasSize).roundToInt())
|
||||
.build()
|
||||
).image?.toBitmap()
|
||||
}
|
||||
}
|
||||
pathImage?.let { bitmap ->
|
||||
drawRepeatedBitmapOnPath(
|
||||
bitmap = bitmap,
|
||||
path = path,
|
||||
paint = paint,
|
||||
interval = drawMode.repeatingInterval.toPx(canvasSize)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
internal fun transformationsForMode(
|
||||
drawMode: DrawMode,
|
||||
canvasSize: IntegerSize
|
||||
): List<Filter<*>> = when (drawMode) {
|
||||
is DrawMode.PathEffect.PrivacyBlur -> {
|
||||
listOf(
|
||||
UiNativeStackBlurFilter(
|
||||
value = drawMode.blurRadius.toFloat() / 1000 * max(canvasSize)
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
is DrawMode.PathEffect.Pixelation -> {
|
||||
listOf(
|
||||
UiNativeStackBlurFilter(
|
||||
value = 20f / 1000 * max(canvasSize)
|
||||
),
|
||||
UiPixelationFilter(
|
||||
value = drawMode.pixelSize / 1000 * max(canvasSize)
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
is DrawMode.PathEffect.Custom -> {
|
||||
drawMode.filter?.let {
|
||||
listOf(it.toUiFilter())
|
||||
} ?: emptyList()
|
||||
}
|
||||
|
||||
else -> emptyList()
|
||||
}
|
||||
+199
@@ -0,0 +1,199 @@
|
||||
/*
|
||||
* 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.draw.presentation.components.utils
|
||||
|
||||
import android.graphics.Bitmap
|
||||
import android.graphics.Color
|
||||
import android.graphics.Path
|
||||
import androidx.compose.ui.geometry.Offset
|
||||
import androidx.compose.ui.graphics.ImageBitmap
|
||||
import androidx.compose.ui.graphics.asAndroidBitmap
|
||||
import androidx.compose.ui.graphics.asComposePath
|
||||
import com.t8rin.imagetoolbox.core.utils.makeLog
|
||||
import java.util.LinkedList
|
||||
import java.util.Queue
|
||||
import kotlin.math.roundToInt
|
||||
import androidx.compose.ui.graphics.Path as ComposePath
|
||||
|
||||
internal class FloodFill(image: Bitmap) {
|
||||
private val path = Path()
|
||||
|
||||
private val width: Int = image.width
|
||||
private val height: Int = image.height
|
||||
private val pixels: IntArray = IntArray(width * height)
|
||||
|
||||
private lateinit var pixelsChecked: BooleanArray
|
||||
private lateinit var ranges: Queue<FloodFillRange>
|
||||
|
||||
private var tolerance = 0
|
||||
|
||||
private var startColorRed = 0
|
||||
private var startColorGreen = 0
|
||||
private var startColorBlue = 0
|
||||
private var startColorAlpha = 0
|
||||
|
||||
init {
|
||||
image.getPixels(pixels, 0, width, 0, 0, width, height)
|
||||
}
|
||||
|
||||
private fun prepare() {
|
||||
// Called before starting flood-fill
|
||||
pixelsChecked = BooleanArray(pixels.size)
|
||||
ranges = LinkedList()
|
||||
}
|
||||
|
||||
// Fills the specified point on the bitmap with the currently selected fill color.
|
||||
// int x, int y: The starting coordinates for the fill
|
||||
fun performFloodFill(
|
||||
x: Int,
|
||||
y: Int,
|
||||
tolerance: Float
|
||||
): Path? {
|
||||
if (x >= width || y >= height || x < 0 || y < 0) return null
|
||||
|
||||
path.rewind()
|
||||
this.tolerance = (tolerance * 255).roundToInt().coerceIn(0, 255)
|
||||
// Setup
|
||||
prepare()
|
||||
|
||||
// Get starting color.
|
||||
val startPixel = pixels.getOrNull(width * y + x) ?: return null
|
||||
startColorRed = Color.red(startPixel)
|
||||
startColorGreen = Color.green(startPixel)
|
||||
startColorBlue = Color.blue(startPixel)
|
||||
startColorAlpha = Color.alpha(startPixel)
|
||||
|
||||
// Do first call to flood-fill.
|
||||
linearFill(x, y)
|
||||
|
||||
// Call flood-fill routine while flood-fill ranges still exist on the queue
|
||||
var range: FloodFillRange
|
||||
while (ranges.isNotEmpty()) {
|
||||
// Get Next Range Off the Queue
|
||||
range = ranges.remove()
|
||||
|
||||
// Check Above and Below Each Pixel in the flood-fill Range
|
||||
var downPxIdx = width * (range.Y + 1) + range.startX
|
||||
var upPxIdx = width * (range.Y - 1) + range.startX
|
||||
val upY = range.Y - 1 // so we can pass the y coordinate by ref
|
||||
val downY = range.Y + 1
|
||||
for (i in range.startX..range.endX) {
|
||||
// Start Fill Upwards
|
||||
// if we're not above the top of the bitmap and the pixel above this one is within the color tolerance
|
||||
if (range.Y > 0 && !pixelsChecked[upPxIdx] && isPixelColorWithinTolerance(upPxIdx)) {
|
||||
linearFill(i, upY)
|
||||
}
|
||||
|
||||
// Start Fill Downwards
|
||||
// if we're not below the bottom of the bitmap and the pixel below this one is within the color tolerance
|
||||
if (
|
||||
range.Y < height - 1 && !pixelsChecked[downPxIdx]
|
||||
&& isPixelColorWithinTolerance(downPxIdx)
|
||||
) {
|
||||
linearFill(i, downY)
|
||||
}
|
||||
downPxIdx++
|
||||
upPxIdx++
|
||||
}
|
||||
}
|
||||
|
||||
return path
|
||||
}
|
||||
|
||||
// Finds the furthermost left and right boundaries of the fill area
|
||||
// on a given y coordinate, starting from a given x coordinate, filling as it goes.
|
||||
// Adds the resulting horizontal range to the queue of flood-fill ranges,
|
||||
// to be processed in the main loop.
|
||||
//
|
||||
// int x, int y: The starting coordinates
|
||||
private fun linearFill(x: Int, y: Int) {
|
||||
// Find Left Edge of Color Area
|
||||
var lFillLoc = x // the location to check/fill on the left
|
||||
var pxIdx = width * y + x
|
||||
path.moveTo(x.toFloat(), y.toFloat())
|
||||
while (true) {
|
||||
pixelsChecked[pxIdx] = true
|
||||
lFillLoc--
|
||||
pxIdx--
|
||||
// exit loop if we're at edge of bitmap or color area
|
||||
if (lFillLoc < 0 || pixelsChecked[pxIdx] || !isPixelColorWithinTolerance(pxIdx)) {
|
||||
break
|
||||
}
|
||||
}
|
||||
vectorFill(pxIdx + 1)
|
||||
lFillLoc++
|
||||
|
||||
// Find Right Edge of Color Area
|
||||
var rFillLoc = x // the location to check/fill on the left
|
||||
pxIdx = width * y + x
|
||||
while (true) {
|
||||
pixelsChecked[pxIdx] = true
|
||||
rFillLoc++
|
||||
pxIdx++
|
||||
if (rFillLoc >= width || pixelsChecked[pxIdx] || !isPixelColorWithinTolerance(pxIdx)) {
|
||||
break
|
||||
}
|
||||
}
|
||||
vectorFill(pxIdx - 1)
|
||||
rFillLoc--
|
||||
|
||||
// add range to queue
|
||||
val r = FloodFillRange(lFillLoc, rFillLoc, y)
|
||||
ranges.offer(r)
|
||||
}
|
||||
|
||||
// vector fill pixels with color
|
||||
private fun vectorFill(pxIndex: Int) {
|
||||
val x = (pxIndex % width).toFloat()
|
||||
val y = (pxIndex - x) / width
|
||||
path.lineTo(x, y)
|
||||
}
|
||||
|
||||
// Sees if a pixel is within the color tolerance range.
|
||||
private fun isPixelColorWithinTolerance(px: Int): Boolean {
|
||||
val alpha = pixels[px] ushr 24 and 0xff
|
||||
val red = pixels[px] ushr 16 and 0xff
|
||||
val green = pixels[px] ushr 8 and 0xff
|
||||
val blue = pixels[px] and 0xff
|
||||
|
||||
return alpha >= startColorAlpha - tolerance && alpha <= startColorAlpha + tolerance &&
|
||||
red >= startColorRed - tolerance && red <= startColorRed + tolerance &&
|
||||
green >= startColorGreen - tolerance && green <= startColorGreen + tolerance &&
|
||||
blue >= startColorBlue - tolerance && blue <= startColorBlue + tolerance
|
||||
}
|
||||
|
||||
// Represents a linear range to be filled and branched from.
|
||||
private data class FloodFillRange(
|
||||
val startX: Int,
|
||||
val endX: Int,
|
||||
val Y: Int
|
||||
)
|
||||
}
|
||||
|
||||
fun ImageBitmap.floodFill(
|
||||
offset: Offset,
|
||||
tolerance: Float
|
||||
): ComposePath? = runCatching {
|
||||
FloodFill(
|
||||
asAndroidBitmap().copy(Bitmap.Config.ARGB_8888, false)
|
||||
).performFloodFill(
|
||||
x = offset.x.roundToInt().coerceIn(0, width - 1),
|
||||
y = offset.y.roundToInt().coerceIn(0, height - 1),
|
||||
tolerance = tolerance
|
||||
)?.asComposePath()
|
||||
}.onFailure { it.makeLog("floodFill") }.getOrNull()
|
||||
+38
@@ -0,0 +1,38 @@
|
||||
/*
|
||||
* 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.draw.presentation.components.utils
|
||||
|
||||
enum class MotionEvent {
|
||||
Idle,
|
||||
Down,
|
||||
Move,
|
||||
Up;
|
||||
}
|
||||
|
||||
inline fun MotionEvent.handle(
|
||||
onDown: () -> Unit,
|
||||
onMove: () -> Unit,
|
||||
onUp: () -> Unit
|
||||
) {
|
||||
when (this) {
|
||||
MotionEvent.Down -> onDown()
|
||||
MotionEvent.Move -> onMove()
|
||||
MotionEvent.Up -> onUp()
|
||||
else -> Unit
|
||||
}
|
||||
}
|
||||
+537
@@ -0,0 +1,537 @@
|
||||
/*
|
||||
* 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.draw.presentation.components.utils
|
||||
|
||||
import android.graphics.Matrix
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.State
|
||||
import androidx.compose.runtime.derivedStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.geometry.Offset
|
||||
import androidx.compose.ui.geometry.Rect
|
||||
import androidx.compose.ui.geometry.RoundRect
|
||||
import androidx.compose.ui.geometry.isSpecified
|
||||
import androidx.compose.ui.geometry.takeOrElse
|
||||
import androidx.compose.ui.graphics.Path
|
||||
import androidx.compose.ui.graphics.PathMeasure
|
||||
import androidx.compose.ui.graphics.asAndroidPath
|
||||
import androidx.compose.ui.graphics.asComposePath
|
||||
import com.t8rin.imagetoolbox.core.domain.model.IntegerSize
|
||||
import com.t8rin.imagetoolbox.core.domain.model.Pt
|
||||
import com.t8rin.imagetoolbox.core.ui.utils.helper.rotate
|
||||
import com.t8rin.imagetoolbox.feature.draw.domain.DrawMode
|
||||
import com.t8rin.imagetoolbox.feature.draw.domain.DrawPathMode
|
||||
import kotlin.math.abs
|
||||
import kotlin.math.cos
|
||||
import kotlin.math.max
|
||||
import kotlin.math.min
|
||||
import kotlin.math.sin
|
||||
import kotlin.math.sqrt
|
||||
import kotlin.random.Random
|
||||
|
||||
data class PathHelper(
|
||||
val drawDownPosition: Offset,
|
||||
val currentDrawPosition: Offset,
|
||||
val onPathChange: (Path) -> Unit,
|
||||
val strokeWidth: Pt,
|
||||
val canvasSize: IntegerSize,
|
||||
val drawPathMode: DrawPathMode,
|
||||
val isEraserOn: Boolean,
|
||||
val drawMode: DrawMode
|
||||
) {
|
||||
private val strokeWidthSized = strokeWidth.toPx(canvasSize)
|
||||
|
||||
private val drawArrowsScope by lazy {
|
||||
object : DrawArrowsScope {
|
||||
override fun drawArrowsIfNeeded(
|
||||
drawPath: Path,
|
||||
) {
|
||||
fun drawStartEndArrows(
|
||||
sizeScale: Float = 3f,
|
||||
angle: Float = 150f
|
||||
) {
|
||||
drawEndArrow(
|
||||
drawPath = drawPath,
|
||||
arrowSize = sizeScale,
|
||||
arrowAngle = angle.toDouble()
|
||||
)
|
||||
|
||||
drawStartArrow(
|
||||
drawPath = drawPath,
|
||||
arrowSize = sizeScale,
|
||||
arrowAngle = angle.toDouble()
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
when (drawPathMode) {
|
||||
is DrawPathMode.DoublePointingArrow -> {
|
||||
drawStartEndArrows(
|
||||
sizeScale = drawPathMode.sizeScale,
|
||||
angle = drawPathMode.angle
|
||||
)
|
||||
}
|
||||
|
||||
is DrawPathMode.DoubleLinePointingArrow -> {
|
||||
drawStartEndArrows(
|
||||
sizeScale = drawPathMode.sizeScale,
|
||||
angle = drawPathMode.angle
|
||||
)
|
||||
}
|
||||
|
||||
is DrawPathMode.PointingArrow -> {
|
||||
drawEndArrow(
|
||||
drawPath = drawPath,
|
||||
arrowSize = drawPathMode.sizeScale,
|
||||
arrowAngle = drawPathMode.angle.toDouble()
|
||||
)
|
||||
}
|
||||
|
||||
is DrawPathMode.LinePointingArrow -> {
|
||||
drawEndArrow(
|
||||
drawPath = drawPath,
|
||||
arrowSize = drawPathMode.sizeScale,
|
||||
arrowAngle = drawPathMode.angle.toDouble()
|
||||
)
|
||||
}
|
||||
|
||||
else -> Unit
|
||||
}
|
||||
}
|
||||
|
||||
private fun drawEndArrow(
|
||||
drawPath: Path,
|
||||
arrowSize: Float = 3f,
|
||||
arrowAngle: Double = 150.0
|
||||
) {
|
||||
val (preLastPoint, lastPoint) = PathMeasure().apply {
|
||||
setPath(drawPath, false)
|
||||
}.let {
|
||||
Pair(
|
||||
it.getPosition(it.length - strokeWidthSized * arrowSize)
|
||||
.takeOrElse { Offset.Zero },
|
||||
it.getPosition(it.length).takeOrElse { Offset.Zero }
|
||||
)
|
||||
}
|
||||
|
||||
val arrowVector = lastPoint - preLastPoint
|
||||
|
||||
fun drawArrow() {
|
||||
val (rx1, ry1) = arrowVector.rotate(arrowAngle)
|
||||
val (rx2, ry2) = arrowVector.rotate(360 - arrowAngle)
|
||||
|
||||
drawPath.apply {
|
||||
relativeLineTo(rx1, ry1)
|
||||
moveTo(lastPoint.x, lastPoint.y)
|
||||
relativeLineTo(rx2, ry2)
|
||||
}
|
||||
}
|
||||
|
||||
if (abs(arrowVector.x) < arrowSize * strokeWidthSized &&
|
||||
abs(arrowVector.y) < arrowSize * strokeWidthSized &&
|
||||
preLastPoint != Offset.Zero
|
||||
) {
|
||||
drawArrow()
|
||||
}
|
||||
}
|
||||
|
||||
private fun drawStartArrow(
|
||||
drawPath: Path,
|
||||
arrowSize: Float = 3f,
|
||||
arrowAngle: Double = 150.0
|
||||
) {
|
||||
val (firstPoint, secondPoint) = PathMeasure().apply {
|
||||
setPath(drawPath, false)
|
||||
}.let {
|
||||
Pair(
|
||||
it.getPosition(0f).takeOrElse { Offset.Zero },
|
||||
it.getPosition(strokeWidthSized * arrowSize)
|
||||
.takeOrElse { Offset.Zero }
|
||||
)
|
||||
}
|
||||
|
||||
val arrowVector = firstPoint - secondPoint
|
||||
|
||||
fun drawArrow() {
|
||||
val (rx1, ry1) = arrowVector.rotate(arrowAngle)
|
||||
val (rx2, ry2) = arrowVector.rotate(360 - arrowAngle)
|
||||
|
||||
drawPath.apply {
|
||||
moveTo(firstPoint.x, firstPoint.y)
|
||||
relativeLineTo(rx1, ry1)
|
||||
moveTo(firstPoint.x, firstPoint.y)
|
||||
relativeLineTo(rx2, ry2)
|
||||
}
|
||||
}
|
||||
|
||||
if (abs(arrowVector.x) < arrowSize * strokeWidthSized &&
|
||||
abs(arrowVector.y) < arrowSize * strokeWidthSized &&
|
||||
secondPoint != Offset.Zero
|
||||
) {
|
||||
drawArrow()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun drawPolygon(
|
||||
vertices: Int,
|
||||
rotationDegrees: Int,
|
||||
isRegular: Boolean,
|
||||
) {
|
||||
if (drawDownPosition.isSpecified && currentDrawPosition.isSpecified) {
|
||||
val top = max(drawDownPosition.y, currentDrawPosition.y)
|
||||
val left = min(drawDownPosition.x, currentDrawPosition.x)
|
||||
val bottom = min(drawDownPosition.y, currentDrawPosition.y)
|
||||
val right = max(drawDownPosition.x, currentDrawPosition.x)
|
||||
|
||||
val width = right - left
|
||||
val height = bottom - top
|
||||
val centerX = (left + right) / 2f
|
||||
val centerY = (top + bottom) / 2f
|
||||
val radius = min(width, height) / 2f
|
||||
|
||||
val newPath = Path().apply {
|
||||
if (isRegular) {
|
||||
val angleStep = 360f / vertices
|
||||
val startAngle = rotationDegrees - 270.0
|
||||
moveTo(
|
||||
centerX + radius * cos(Math.toRadians(startAngle)).toFloat(),
|
||||
centerY + radius * sin(Math.toRadians(startAngle)).toFloat()
|
||||
)
|
||||
for (i in 1 until vertices) {
|
||||
val angle = startAngle + i * angleStep
|
||||
lineTo(
|
||||
centerX + radius * cos(Math.toRadians(angle)).toFloat(),
|
||||
centerY + radius * sin(Math.toRadians(angle)).toFloat()
|
||||
)
|
||||
}
|
||||
} else {
|
||||
for (i in 0 until vertices) {
|
||||
val angle = i * (360f / vertices) + rotationDegrees - 270.0
|
||||
val x =
|
||||
centerX + width / 2f * cos(Math.toRadians(angle)).toFloat()
|
||||
val y =
|
||||
centerY + height / 2f * sin(Math.toRadians(angle)).toFloat()
|
||||
if (i == 0) {
|
||||
moveTo(x, y)
|
||||
} else {
|
||||
lineTo(x, y)
|
||||
}
|
||||
}
|
||||
}
|
||||
close()
|
||||
}
|
||||
onPathChange(newPath)
|
||||
}
|
||||
}
|
||||
|
||||
fun drawStar(
|
||||
vertices: Int,
|
||||
innerRadiusRatio: Float,
|
||||
rotationDegrees: Int,
|
||||
isRegular: Boolean,
|
||||
) {
|
||||
if (drawDownPosition.isSpecified && currentDrawPosition.isSpecified) {
|
||||
val top = max(drawDownPosition.y, currentDrawPosition.y)
|
||||
val left = min(drawDownPosition.x, currentDrawPosition.x)
|
||||
val bottom = min(drawDownPosition.y, currentDrawPosition.y)
|
||||
val right = max(drawDownPosition.x, currentDrawPosition.x)
|
||||
|
||||
val centerX = (left + right) / 2f
|
||||
val centerY = (top + bottom) / 2f
|
||||
val width = right - left
|
||||
val height = bottom - top
|
||||
|
||||
val newPath = Path().apply {
|
||||
if (isRegular) {
|
||||
val outerRadius = min(width, height) / 2f
|
||||
val innerRadius = outerRadius * innerRadiusRatio
|
||||
|
||||
val angleStep = 360f / (2 * vertices)
|
||||
val startAngle = rotationDegrees - 270.0
|
||||
|
||||
for (i in 0 until (2 * vertices)) {
|
||||
val radius = if (i % 2 == 0) outerRadius else innerRadius
|
||||
val angle = startAngle + i * angleStep
|
||||
val x = centerX + radius * cos(Math.toRadians(angle)).toFloat()
|
||||
val y = centerY + radius * sin(Math.toRadians(angle)).toFloat()
|
||||
if (i == 0) {
|
||||
moveTo(x, y)
|
||||
} else {
|
||||
lineTo(x, y)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
for (i in 0 until (2 * vertices)) {
|
||||
val angle = i * (360f / (2 * vertices)) + rotationDegrees - 270.0
|
||||
val radiusX =
|
||||
(if (i % 2 == 0) width else width * innerRadiusRatio) / 2f
|
||||
val radiusY =
|
||||
(if (i % 2 == 0) height else height * innerRadiusRatio) / 2f
|
||||
|
||||
val x = centerX + radiusX * cos(Math.toRadians(angle)).toFloat()
|
||||
val y = centerY + radiusY * sin(Math.toRadians(angle)).toFloat()
|
||||
if (i == 0) {
|
||||
moveTo(x, y)
|
||||
} else {
|
||||
lineTo(x, y)
|
||||
}
|
||||
}
|
||||
}
|
||||
close()
|
||||
}
|
||||
|
||||
onPathChange(newPath)
|
||||
}
|
||||
}
|
||||
|
||||
fun drawTriangle() {
|
||||
if (drawDownPosition.isSpecified && currentDrawPosition.isSpecified) {
|
||||
val newPath = Path().apply {
|
||||
moveTo(drawDownPosition.x, drawDownPosition.y)
|
||||
|
||||
lineTo(currentDrawPosition.x, drawDownPosition.y)
|
||||
lineTo(
|
||||
(drawDownPosition.x + currentDrawPosition.x) / 2,
|
||||
currentDrawPosition.y
|
||||
)
|
||||
lineTo(drawDownPosition.x, drawDownPosition.y)
|
||||
close()
|
||||
}
|
||||
onPathChange(newPath)
|
||||
}
|
||||
}
|
||||
|
||||
fun drawRect(
|
||||
rotationDegrees: Int,
|
||||
cornerRadius: Float
|
||||
) {
|
||||
if (!drawDownPosition.isSpecified || !currentDrawPosition.isSpecified) return
|
||||
|
||||
val left = min(drawDownPosition.x, currentDrawPosition.x)
|
||||
val right = max(drawDownPosition.x, currentDrawPosition.x)
|
||||
val top = min(drawDownPosition.y, currentDrawPosition.y)
|
||||
val bottom = max(drawDownPosition.y, currentDrawPosition.y)
|
||||
|
||||
val width = right - left
|
||||
val height = bottom - top
|
||||
if (width <= 0f || height <= 0f) return
|
||||
|
||||
val radius = min(width, height) * cornerRadius.coerceIn(0f, 0.5f)
|
||||
val centerX = (left + right) / 2f
|
||||
val centerY = (top + bottom) / 2f
|
||||
|
||||
val path = Path().apply {
|
||||
addRoundRect(
|
||||
RoundRect(
|
||||
rect = Rect(left, top, right, bottom),
|
||||
radius, radius
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
val matrix = Matrix().apply {
|
||||
setRotate(rotationDegrees.toFloat(), centerX, centerY)
|
||||
}
|
||||
|
||||
|
||||
onPathChange(
|
||||
path.asAndroidPath().apply { transform(matrix) }.asComposePath()
|
||||
)
|
||||
}
|
||||
|
||||
fun drawOval() {
|
||||
if (drawDownPosition.isSpecified && currentDrawPosition.isSpecified) {
|
||||
val newPath = Path().apply {
|
||||
addOval(
|
||||
Rect(
|
||||
top = max(
|
||||
drawDownPosition.y,
|
||||
currentDrawPosition.y
|
||||
),
|
||||
left = min(
|
||||
drawDownPosition.x,
|
||||
currentDrawPosition.x
|
||||
),
|
||||
bottom = min(
|
||||
drawDownPosition.y,
|
||||
currentDrawPosition.y
|
||||
),
|
||||
right = max(
|
||||
drawDownPosition.x,
|
||||
currentDrawPosition.x
|
||||
),
|
||||
)
|
||||
)
|
||||
}
|
||||
onPathChange(newPath)
|
||||
}
|
||||
}
|
||||
|
||||
fun drawLine() {
|
||||
if (drawDownPosition.isSpecified && currentDrawPosition.isSpecified) {
|
||||
val newPath = Path().apply {
|
||||
moveTo(drawDownPosition.x, drawDownPosition.y)
|
||||
lineTo(currentDrawPosition.x, currentDrawPosition.y)
|
||||
}
|
||||
drawArrowsScope.drawArrowsIfNeeded(newPath)
|
||||
|
||||
onPathChange(newPath)
|
||||
}
|
||||
}
|
||||
|
||||
fun drawPath(
|
||||
currentDrawPath: Path? = null,
|
||||
onDrawFreeArrow: DrawArrowsScope.() -> Unit = {},
|
||||
onBaseDraw: () -> Unit = {},
|
||||
onFloodFill: (tolerance: Float) -> Unit = {}
|
||||
) {
|
||||
if (!isEraserOn && drawMode !is DrawMode.Warp) {
|
||||
when (drawPathMode) {
|
||||
is DrawPathMode.PointingArrow,
|
||||
is DrawPathMode.DoublePointingArrow -> onDrawFreeArrow(drawArrowsScope)
|
||||
|
||||
is DrawPathMode.DoubleLinePointingArrow,
|
||||
DrawPathMode.Line,
|
||||
is DrawPathMode.LinePointingArrow -> drawLine()
|
||||
|
||||
is DrawPathMode.Rect -> drawRect(
|
||||
rotationDegrees = drawPathMode.rotationDegrees,
|
||||
cornerRadius = drawPathMode.cornerRadius
|
||||
)
|
||||
|
||||
is DrawPathMode.OutlinedRect -> drawRect(
|
||||
rotationDegrees = drawPathMode.rotationDegrees,
|
||||
cornerRadius = drawPathMode.cornerRadius
|
||||
)
|
||||
|
||||
is DrawPathMode.Triangle,
|
||||
is DrawPathMode.OutlinedTriangle -> drawTriangle()
|
||||
|
||||
is DrawPathMode.Polygon -> {
|
||||
drawPolygon(
|
||||
vertices = drawPathMode.vertices,
|
||||
rotationDegrees = drawPathMode.rotationDegrees,
|
||||
isRegular = drawPathMode.isRegular
|
||||
)
|
||||
}
|
||||
|
||||
is DrawPathMode.OutlinedPolygon -> {
|
||||
drawPolygon(
|
||||
vertices = drawPathMode.vertices,
|
||||
rotationDegrees = drawPathMode.rotationDegrees,
|
||||
isRegular = drawPathMode.isRegular
|
||||
)
|
||||
}
|
||||
|
||||
is DrawPathMode.Star -> {
|
||||
drawStar(
|
||||
vertices = drawPathMode.vertices,
|
||||
innerRadiusRatio = drawPathMode.innerRadiusRatio,
|
||||
rotationDegrees = drawPathMode.rotationDegrees,
|
||||
isRegular = drawPathMode.isRegular
|
||||
)
|
||||
}
|
||||
|
||||
is DrawPathMode.OutlinedStar -> {
|
||||
drawStar(
|
||||
vertices = drawPathMode.vertices,
|
||||
innerRadiusRatio = drawPathMode.innerRadiusRatio,
|
||||
rotationDegrees = drawPathMode.rotationDegrees,
|
||||
isRegular = drawPathMode.isRegular
|
||||
)
|
||||
}
|
||||
|
||||
is DrawPathMode.Oval,
|
||||
is DrawPathMode.OutlinedOval -> drawOval()
|
||||
|
||||
is DrawPathMode.Free,
|
||||
is DrawPathMode.Lasso -> onBaseDraw()
|
||||
|
||||
is DrawPathMode.FloodFill -> onFloodFill(drawPathMode.tolerance)
|
||||
|
||||
is DrawPathMode.Spray -> {
|
||||
currentDrawPath?.let {
|
||||
val path = currentDrawPath.copy().apply {
|
||||
repeat(drawPathMode.density) {
|
||||
val angle = Random.nextFloat() * PI_2
|
||||
val radius = sqrt(Random.nextFloat()) * strokeWidthSized
|
||||
val x = currentDrawPosition.x + radius * cos(angle)
|
||||
val y = currentDrawPosition.y + radius * sin(angle)
|
||||
|
||||
val rect = Rect(
|
||||
left = x,
|
||||
top = y,
|
||||
right = x + drawPathMode.pixelSize,
|
||||
bottom = y + drawPathMode.pixelSize
|
||||
)
|
||||
|
||||
if (drawPathMode.isSquareShaped) addRect(rect) else addOval(rect)
|
||||
}
|
||||
}
|
||||
|
||||
onPathChange(path)
|
||||
}
|
||||
}
|
||||
}
|
||||
} else onBaseDraw()
|
||||
}
|
||||
}
|
||||
|
||||
private const val PI_2 = (Math.PI * 2).toFloat()
|
||||
|
||||
interface DrawArrowsScope {
|
||||
fun drawArrowsIfNeeded(
|
||||
drawPath: Path,
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun rememberPathHelper(
|
||||
drawDownPosition: Offset,
|
||||
currentDrawPosition: Offset,
|
||||
onPathChange: (Path) -> Unit,
|
||||
strokeWidth: Pt,
|
||||
canvasSize: IntegerSize,
|
||||
drawPathMode: DrawPathMode,
|
||||
isEraserOn: Boolean,
|
||||
drawMode: DrawMode
|
||||
): State<PathHelper> = remember(
|
||||
drawDownPosition,
|
||||
currentDrawPosition,
|
||||
onPathChange,
|
||||
strokeWidth,
|
||||
canvasSize,
|
||||
drawPathMode,
|
||||
isEraserOn,
|
||||
drawMode
|
||||
) {
|
||||
derivedStateOf {
|
||||
PathHelper(
|
||||
drawDownPosition = drawDownPosition,
|
||||
currentDrawPosition = currentDrawPosition,
|
||||
onPathChange = onPathChange,
|
||||
strokeWidth = strokeWidth,
|
||||
canvasSize = canvasSize,
|
||||
drawPathMode = drawPathMode,
|
||||
isEraserOn = isEraserOn,
|
||||
drawMode = drawMode
|
||||
)
|
||||
}
|
||||
}
|
||||
+130
@@ -0,0 +1,130 @@
|
||||
/*
|
||||
* 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.draw.presentation.components.utils
|
||||
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.magnifier
|
||||
import androidx.compose.runtime.MutableIntState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.composed
|
||||
import androidx.compose.ui.draw.clipToBounds
|
||||
import androidx.compose.ui.geometry.Offset
|
||||
import androidx.compose.ui.geometry.isSpecified
|
||||
import androidx.compose.ui.unit.DpSize
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.t8rin.gesture.pointerMotionEvents
|
||||
import com.t8rin.imagetoolbox.core.ui.widget.modifier.observePointersCountWithOffset
|
||||
import com.t8rin.imagetoolbox.core.ui.widget.modifier.smartDelayAfterDownInMillis
|
||||
import net.engawapg.lib.zoomable.ZoomState
|
||||
import net.engawapg.lib.zoomable.ZoomableDefaults.defaultZoomOnDoubleTap
|
||||
import net.engawapg.lib.zoomable.zoomable
|
||||
|
||||
fun Modifier.pointerDrawObserver(
|
||||
magnifierEnabled: Boolean,
|
||||
currentDrawPosition: Offset,
|
||||
zoomState: ZoomState,
|
||||
globalTouchPointersCount: MutableIntState,
|
||||
panEnabled: Boolean
|
||||
) = this.composed {
|
||||
var globalTouchPosition by remember { mutableStateOf(Offset.Unspecified) }
|
||||
|
||||
Modifier
|
||||
.fillMaxSize()
|
||||
.observePointersCountWithOffset { size, offset ->
|
||||
globalTouchPointersCount.intValue = size
|
||||
globalTouchPosition = offset
|
||||
}
|
||||
.then(
|
||||
if (magnifierEnabled) {
|
||||
Modifier.magnifier(
|
||||
sourceCenter = {
|
||||
if (currentDrawPosition.isSpecified) {
|
||||
globalTouchPosition
|
||||
} else Offset.Unspecified
|
||||
},
|
||||
magnifierCenter = {
|
||||
globalTouchPosition - Offset(0f, 100.dp.toPx())
|
||||
},
|
||||
size = DpSize(height = 100.dp, width = 100.dp),
|
||||
cornerRadius = 50.dp,
|
||||
elevation = 2.dp
|
||||
)
|
||||
} else Modifier
|
||||
)
|
||||
.clipToBounds()
|
||||
.zoomable(
|
||||
zoomState = zoomState,
|
||||
zoomEnabled = (globalTouchPointersCount.intValue >= 2 || panEnabled),
|
||||
enableOneFingerZoom = panEnabled,
|
||||
onDoubleTap = { pos ->
|
||||
if (panEnabled) zoomState.defaultZoomOnDoubleTap(pos)
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
fun Modifier.pointerDrawHandler(
|
||||
globalTouchPointersCount: MutableIntState,
|
||||
onReceiveMotionEvent: (MotionEvent) -> Unit,
|
||||
onInvalidate: () -> Unit,
|
||||
onUpdateCurrentDrawPosition: (Offset) -> Unit,
|
||||
onUpdateDrawDownPosition: (Offset) -> Unit,
|
||||
enabled: Boolean
|
||||
) = if (enabled) {
|
||||
this.composed {
|
||||
var drawStartedWithOnePointer by remember {
|
||||
mutableStateOf(false)
|
||||
}
|
||||
|
||||
Modifier.pointerMotionEvents(
|
||||
onDown = { pointerInputChange ->
|
||||
drawStartedWithOnePointer = globalTouchPointersCount.intValue <= 1
|
||||
|
||||
if (drawStartedWithOnePointer) {
|
||||
onReceiveMotionEvent(MotionEvent.Down)
|
||||
onUpdateCurrentDrawPosition(pointerInputChange.position)
|
||||
onUpdateDrawDownPosition(pointerInputChange.position)
|
||||
pointerInputChange.consume()
|
||||
onInvalidate()
|
||||
}
|
||||
},
|
||||
onMove = { pointerInputChange ->
|
||||
if (drawStartedWithOnePointer) {
|
||||
onReceiveMotionEvent(MotionEvent.Move)
|
||||
onUpdateCurrentDrawPosition(pointerInputChange.position)
|
||||
pointerInputChange.consume()
|
||||
onInvalidate()
|
||||
}
|
||||
},
|
||||
onUp = { pointerInputChange ->
|
||||
if (drawStartedWithOnePointer) {
|
||||
onReceiveMotionEvent(MotionEvent.Up)
|
||||
pointerInputChange.consume()
|
||||
onInvalidate()
|
||||
}
|
||||
drawStartedWithOnePointer = false
|
||||
},
|
||||
delayAfterDownInMillis = smartDelayAfterDownInMillis(globalTouchPointersCount.intValue)
|
||||
)
|
||||
}
|
||||
} else {
|
||||
this
|
||||
}
|
||||
+489
@@ -0,0 +1,489 @@
|
||||
/*
|
||||
* 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.draw.presentation.screenLogic
|
||||
|
||||
import android.content.pm.ActivityInfo
|
||||
import android.graphics.Bitmap
|
||||
import android.net.Uri
|
||||
import androidx.compose.runtime.MutableState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateMapOf
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.ImageBitmap
|
||||
import androidx.compose.ui.graphics.Path
|
||||
import androidx.compose.ui.graphics.asImageBitmap
|
||||
import androidx.compose.ui.graphics.toArgb
|
||||
import androidx.core.net.toUri
|
||||
import com.arkivanov.decompose.ComponentContext
|
||||
import com.arkivanov.decompose.childContext
|
||||
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.ImageScaler
|
||||
import com.t8rin.imagetoolbox.core.domain.image.ImageShareProvider
|
||||
import com.t8rin.imagetoolbox.core.domain.image.ImageTransformer
|
||||
import com.t8rin.imagetoolbox.core.domain.image.model.ImageFormat
|
||||
import com.t8rin.imagetoolbox.core.domain.image.model.ImageInfo
|
||||
import com.t8rin.imagetoolbox.core.domain.saving.FileController
|
||||
import com.t8rin.imagetoolbox.core.domain.saving.model.ImageSaveTarget
|
||||
import com.t8rin.imagetoolbox.core.domain.utils.smartJob
|
||||
import com.t8rin.imagetoolbox.core.domain.utils.update
|
||||
import com.t8rin.imagetoolbox.core.filters.domain.FilterProvider
|
||||
import com.t8rin.imagetoolbox.core.filters.domain.model.Filter
|
||||
import com.t8rin.imagetoolbox.core.filters.presentation.widget.FilterTemplateCreationSheetComponent
|
||||
import com.t8rin.imagetoolbox.core.filters.presentation.widget.addFilters.AddFiltersSheetComponent
|
||||
import com.t8rin.imagetoolbox.core.settings.domain.SettingsProvider
|
||||
import com.t8rin.imagetoolbox.core.ui.utils.BaseComponent
|
||||
import com.t8rin.imagetoolbox.core.ui.utils.helper.AppToastHost
|
||||
import com.t8rin.imagetoolbox.core.ui.utils.navigation.Screen
|
||||
import com.t8rin.imagetoolbox.core.ui.utils.state.savable
|
||||
import com.t8rin.imagetoolbox.core.ui.utils.state.update
|
||||
import com.t8rin.imagetoolbox.core.ui.widget.modifier.HelperGridParams
|
||||
import com.t8rin.imagetoolbox.feature.draw.domain.DrawBehavior
|
||||
import com.t8rin.imagetoolbox.feature.draw.domain.DrawLineStyle
|
||||
import com.t8rin.imagetoolbox.feature.draw.domain.DrawMode
|
||||
import com.t8rin.imagetoolbox.feature.draw.domain.DrawOnBackgroundParams
|
||||
import com.t8rin.imagetoolbox.feature.draw.domain.DrawPathMode
|
||||
import com.t8rin.imagetoolbox.feature.draw.domain.ImageDrawApplier
|
||||
import com.t8rin.imagetoolbox.feature.draw.presentation.components.UiPathPaint
|
||||
import dagger.assisted.Assisted
|
||||
import dagger.assisted.AssistedFactory
|
||||
import dagger.assisted.AssistedInject
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.withContext
|
||||
|
||||
class DrawComponent @AssistedInject internal constructor(
|
||||
@Assisted componentContext: ComponentContext,
|
||||
@Assisted val initialUri: Uri?,
|
||||
@Assisted val onGoBack: () -> Unit,
|
||||
@Assisted val onNavigate: (Screen) -> Unit,
|
||||
private val fileController: FileController,
|
||||
private val imageTransformer: ImageTransformer<Bitmap>,
|
||||
private val imageCompressor: ImageCompressor<Bitmap>,
|
||||
private val imageDrawApplier: ImageDrawApplier<Bitmap, Path, Color>,
|
||||
private val imageGetter: ImageGetter<Bitmap>,
|
||||
private val imageScaler: ImageScaler<Bitmap>,
|
||||
private val shareProvider: ImageShareProvider<Bitmap>,
|
||||
private val filterProvider: FilterProvider<Bitmap>,
|
||||
private val settingsProvider: SettingsProvider,
|
||||
dispatchersHolder: DispatchersHolder,
|
||||
addFiltersSheetComponentFactory: AddFiltersSheetComponent.Factory,
|
||||
filterTemplateCreationSheetComponentFactory: FilterTemplateCreationSheetComponent.Factory
|
||||
) : BaseComponent(dispatchersHolder, componentContext) {
|
||||
|
||||
init {
|
||||
debounce {
|
||||
initialUri?.let(::setUri)
|
||||
}
|
||||
}
|
||||
|
||||
val addFiltersSheetComponent: AddFiltersSheetComponent = addFiltersSheetComponentFactory(
|
||||
componentContext = componentContext.childContext(
|
||||
key = "addFilters"
|
||||
)
|
||||
)
|
||||
|
||||
val filterTemplateCreationSheetComponent: FilterTemplateCreationSheetComponent =
|
||||
filterTemplateCreationSheetComponentFactory(
|
||||
componentContext = componentContext.childContext(
|
||||
key = "filterTemplateCreationSheetComponentDraw"
|
||||
)
|
||||
)
|
||||
|
||||
private val _drawOnBackgroundParams = fileController.savable(
|
||||
scope = componentScope,
|
||||
initial = DrawOnBackgroundParams.Default
|
||||
)
|
||||
val drawOnBackgroundParams: DrawOnBackgroundParams by _drawOnBackgroundParams
|
||||
|
||||
private val _imageBitmap: MutableState<ImageBitmap?> = mutableStateOf(null)
|
||||
val imageBitmap: ImageBitmap? by _imageBitmap
|
||||
|
||||
private val _backgroundColor: MutableState<Color> = mutableStateOf(Color.Transparent)
|
||||
val backgroundColor by _backgroundColor
|
||||
|
||||
private val _colorPickerBitmap: MutableState<Bitmap?> = mutableStateOf(null)
|
||||
val colorPickerBitmap by _colorPickerBitmap
|
||||
|
||||
private val _drawBehavior: MutableState<DrawBehavior> = mutableStateOf(DrawBehavior.None)
|
||||
val drawBehavior: DrawBehavior by _drawBehavior
|
||||
|
||||
private val _drawMode: MutableState<DrawMode> = mutableStateOf(DrawMode.Pen)
|
||||
val drawMode: DrawMode by _drawMode
|
||||
|
||||
private val _drawPathMode: MutableState<DrawPathMode> = mutableStateOf(DrawPathMode.Free)
|
||||
val drawPathMode: DrawPathMode by _drawPathMode
|
||||
|
||||
private val _drawLineStyle: MutableState<DrawLineStyle> = mutableStateOf(DrawLineStyle.None)
|
||||
val drawLineStyle: DrawLineStyle by _drawLineStyle
|
||||
|
||||
private val _uri = mutableStateOf(Uri.EMPTY)
|
||||
val uri: Uri by _uri
|
||||
|
||||
private val _paths = mutableStateOf(listOf<UiPathPaint>())
|
||||
val paths: List<UiPathPaint> by _paths
|
||||
|
||||
private val _lastPaths = mutableStateOf(listOf<UiPathPaint>())
|
||||
val lastPaths: List<UiPathPaint> by _lastPaths
|
||||
|
||||
private val _undonePaths = mutableStateOf(listOf<UiPathPaint>())
|
||||
val undonePaths: List<UiPathPaint> by _undonePaths
|
||||
|
||||
private val _spotHealCache = mutableStateMapOf<Int, Bitmap>()
|
||||
val spotHealCache: Map<Int, Bitmap> = _spotHealCache
|
||||
|
||||
val havePaths: Boolean
|
||||
get() = paths.isNotEmpty() || lastPaths.isNotEmpty() || undonePaths.isNotEmpty()
|
||||
|
||||
private val _imageFormat = mutableStateOf(ImageFormat.Default)
|
||||
val imageFormat by _imageFormat
|
||||
|
||||
private val _isSaving: MutableState<Boolean> = mutableStateOf(false)
|
||||
val isSaving: Boolean by _isSaving
|
||||
|
||||
private val _saveExif: MutableState<Boolean> = mutableStateOf(false)
|
||||
val saveExif: Boolean by _saveExif
|
||||
|
||||
private val _helperGridParams = fileController.savable(
|
||||
scope = componentScope,
|
||||
initial = HelperGridParams()
|
||||
)
|
||||
val helperGridParams: HelperGridParams by _helperGridParams
|
||||
|
||||
init {
|
||||
componentScope.launch {
|
||||
val settingsState = settingsProvider.getSettingsState()
|
||||
_drawPathMode.update { DrawPathMode.fromOrdinal(settingsState.defaultDrawPathMode) }
|
||||
}
|
||||
}
|
||||
|
||||
private var savingJob: Job? by smartJob {
|
||||
_isSaving.update { false }
|
||||
}
|
||||
|
||||
fun saveBitmap(
|
||||
oneTimeSaveLocationUri: String?
|
||||
) {
|
||||
savingJob = trackProgress {
|
||||
_isSaving.value = true
|
||||
getDrawingBitmap()?.let { localBitmap ->
|
||||
parseSaveResult(
|
||||
fileController.save(
|
||||
saveTarget = ImageSaveTarget(
|
||||
imageInfo = ImageInfo(
|
||||
originalUri = _uri.value.toString(),
|
||||
imageFormat = imageFormat,
|
||||
width = localBitmap.width,
|
||||
height = localBitmap.height
|
||||
),
|
||||
originalUri = _uri.value.toString(),
|
||||
sequenceNumber = null,
|
||||
data = imageCompressor.compressAndTransform(
|
||||
image = localBitmap,
|
||||
imageInfo = ImageInfo(
|
||||
originalUri = _uri.value.toString(),
|
||||
imageFormat = imageFormat,
|
||||
width = localBitmap.width,
|
||||
height = localBitmap.height
|
||||
)
|
||||
)
|
||||
),
|
||||
keepOriginalMetadata = _saveExif.value,
|
||||
oneTimeSaveLocationUri = oneTimeSaveLocationUri
|
||||
).onSuccess(::registerSave)
|
||||
)
|
||||
}
|
||||
_isSaving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
private fun calculateScreenOrientationBasedOnBitmap(bitmap: Bitmap): Int {
|
||||
val imageRatio = bitmap.width / bitmap.height.toFloat()
|
||||
return if (imageRatio <= 1.05f) {
|
||||
ActivityInfo.SCREEN_ORIENTATION_PORTRAIT
|
||||
} else {
|
||||
ActivityInfo.SCREEN_ORIENTATION_LANDSCAPE
|
||||
}
|
||||
}
|
||||
|
||||
fun setImageFormat(imageFormat: ImageFormat) {
|
||||
_imageFormat.value = imageFormat
|
||||
registerChanges()
|
||||
}
|
||||
|
||||
fun setSaveExif(bool: Boolean) {
|
||||
_saveExif.value = bool
|
||||
registerChanges()
|
||||
}
|
||||
|
||||
private fun updateBitmap(bitmap: Bitmap?) {
|
||||
componentScope.launch {
|
||||
_isImageLoading.value = true
|
||||
val scaledBitmap = imageScaler.scaleUntilCanShow(bitmap)
|
||||
val scaledImageBitmap = scaledBitmap?.let {
|
||||
withContext(defaultDispatcher) {
|
||||
it.copy(Bitmap.Config.ARGB_8888, true).asImageBitmap()
|
||||
}
|
||||
}
|
||||
_imageBitmap.value = scaledImageBitmap
|
||||
_isImageLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
fun setUri(
|
||||
uri: Uri
|
||||
) {
|
||||
componentScope.launch {
|
||||
_paths.value = listOf()
|
||||
_lastPaths.value = listOf()
|
||||
_undonePaths.value = listOf()
|
||||
_spotHealCache.clear()
|
||||
_imageBitmap.value = null
|
||||
_isImageLoading.value = true
|
||||
|
||||
_uri.value = uri
|
||||
imageGetter.getImageData(
|
||||
uri = uri.toString(),
|
||||
size = 2500,
|
||||
onFailure = AppToastHost::showFailureToast
|
||||
)?.let { data ->
|
||||
if (drawBehavior !is DrawBehavior.Background) {
|
||||
_drawBehavior.update {
|
||||
DrawBehavior.Image(calculateScreenOrientationBasedOnBitmap(data.image))
|
||||
}
|
||||
}
|
||||
updateBitmap(data.image)
|
||||
_imageFormat.update { data.imageInfo.imageFormat }
|
||||
} ?: run {
|
||||
_isImageLoading.value = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun getDrawingBitmap(): Bitmap? = withContext(defaultDispatcher) {
|
||||
imageDrawApplier.applyDrawToImage(
|
||||
drawBehavior = drawBehavior.let {
|
||||
if (it is DrawBehavior.Background) it.copy(color = backgroundColor.toArgb())
|
||||
else it
|
||||
},
|
||||
pathPaints = paths,
|
||||
imageUri = _uri.value.toString()
|
||||
)
|
||||
}
|
||||
|
||||
fun openColorPicker() {
|
||||
componentScope.launch {
|
||||
_colorPickerBitmap.value = getDrawingBitmap()
|
||||
}
|
||||
}
|
||||
|
||||
fun resetDrawBehavior() {
|
||||
_paths.value = listOf()
|
||||
_lastPaths.value = listOf()
|
||||
_undonePaths.value = listOf()
|
||||
_spotHealCache.clear()
|
||||
_imageBitmap.value = null
|
||||
_drawBehavior.update {
|
||||
DrawBehavior.None
|
||||
}
|
||||
_drawPathMode.update { DrawPathMode.Free }
|
||||
_uri.value = Uri.EMPTY
|
||||
_backgroundColor.value = Color.Transparent
|
||||
registerChangesCleared()
|
||||
}
|
||||
|
||||
fun startDrawOnBackground(
|
||||
reqWidth: Int,
|
||||
reqHeight: Int,
|
||||
color: Color,
|
||||
) {
|
||||
val width = reqWidth.takeIf { it > 0 } ?: 1
|
||||
val height = reqHeight.takeIf { it > 0 } ?: 1
|
||||
val imageRatio = width / height.toFloat()
|
||||
_drawBehavior.update {
|
||||
DrawBehavior.Background(
|
||||
orientation = if (imageRatio <= 1f) {
|
||||
ActivityInfo.SCREEN_ORIENTATION_PORTRAIT
|
||||
} else {
|
||||
ActivityInfo.SCREEN_ORIENTATION_LANDSCAPE
|
||||
},
|
||||
width = width,
|
||||
height = height,
|
||||
color = color.toArgb()
|
||||
)
|
||||
}
|
||||
_backgroundColor.value = color
|
||||
_spotHealCache.clear()
|
||||
|
||||
componentScope.launch {
|
||||
val newValue = DrawOnBackgroundParams(
|
||||
width = width,
|
||||
height = height,
|
||||
color = color.toArgb()
|
||||
)
|
||||
|
||||
_drawOnBackgroundParams.update { newValue }
|
||||
}
|
||||
}
|
||||
|
||||
fun shareBitmap() {
|
||||
savingJob = trackProgress {
|
||||
_isSaving.value = true
|
||||
getDrawingBitmap()?.let {
|
||||
shareProvider.shareImage(
|
||||
image = it,
|
||||
imageInfo = ImageInfo(
|
||||
originalUri = _uri.value.toString(),
|
||||
imageFormat = imageFormat,
|
||||
width = it.width,
|
||||
height = it.height
|
||||
),
|
||||
onComplete = AppToastHost::showConfetti
|
||||
)
|
||||
}
|
||||
_isSaving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
fun updateBackgroundColor(color: Color) {
|
||||
_backgroundColor.value = color
|
||||
_spotHealCache.clear()
|
||||
registerChanges()
|
||||
}
|
||||
|
||||
fun clearDrawing() {
|
||||
if (paths.isNotEmpty()) {
|
||||
_lastPaths.value = paths
|
||||
_paths.value = listOf()
|
||||
_undonePaths.value = listOf()
|
||||
_spotHealCache.clear()
|
||||
registerChanges()
|
||||
}
|
||||
}
|
||||
|
||||
fun undo() {
|
||||
if (paths.isEmpty() && lastPaths.isNotEmpty()) {
|
||||
_paths.value = lastPaths
|
||||
_lastPaths.value = listOf()
|
||||
return
|
||||
}
|
||||
if (paths.isEmpty()) return
|
||||
|
||||
val lastPath = paths.last()
|
||||
|
||||
_paths.update { it - lastPath }
|
||||
_undonePaths.update { it + lastPath }
|
||||
registerChanges()
|
||||
}
|
||||
|
||||
fun redo() {
|
||||
if (undonePaths.isEmpty()) return
|
||||
|
||||
val lastPath = undonePaths.last()
|
||||
_paths.update { it + lastPath }
|
||||
_undonePaths.update { it - lastPath }
|
||||
registerChanges()
|
||||
}
|
||||
|
||||
fun addPath(pathPaint: UiPathPaint) {
|
||||
_paths.update { it + pathPaint }
|
||||
_undonePaths.value = listOf()
|
||||
registerChanges()
|
||||
}
|
||||
|
||||
fun removePath(pathPaint: UiPathPaint) {
|
||||
_paths.update { it - pathPaint }
|
||||
_spotHealCache.remove(pathPaint.hashCode())
|
||||
registerChanges()
|
||||
}
|
||||
|
||||
fun cacheSpotHealPathResult(
|
||||
key: Int,
|
||||
bitmap: Bitmap
|
||||
) {
|
||||
_spotHealCache[key] = bitmap
|
||||
}
|
||||
|
||||
fun cancelSaving() {
|
||||
savingJob?.cancel()
|
||||
savingJob = null
|
||||
_isSaving.value = false
|
||||
}
|
||||
|
||||
suspend fun filter(
|
||||
bitmap: Bitmap,
|
||||
filters: List<Filter<*>>,
|
||||
): Bitmap? = imageTransformer.transform(
|
||||
image = bitmap,
|
||||
transformations = filters.map { filterProvider.filterToTransformation(it) }
|
||||
)
|
||||
|
||||
fun cacheCurrentImage(onComplete: (Uri) -> Unit) {
|
||||
savingJob = trackProgress {
|
||||
_isSaving.value = true
|
||||
getDrawingBitmap()?.let { image ->
|
||||
shareProvider.cacheImage(
|
||||
image = image,
|
||||
imageInfo = ImageInfo(
|
||||
originalUri = _uri.value.toString(),
|
||||
imageFormat = imageFormat,
|
||||
width = image.width,
|
||||
height = image.height
|
||||
)
|
||||
)?.let { uri ->
|
||||
onComplete(uri.toUri())
|
||||
}
|
||||
}
|
||||
_isSaving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
fun updateDrawMode(drawMode: DrawMode) {
|
||||
_drawMode.update { drawMode }
|
||||
|
||||
if (drawMode is DrawMode.Warp) {
|
||||
_drawPathMode.update { DrawPathMode.Free }
|
||||
_drawLineStyle.update { DrawLineStyle.None }
|
||||
}
|
||||
}
|
||||
|
||||
fun updateDrawPathMode(drawPathMode: DrawPathMode) {
|
||||
_drawPathMode.update { drawPathMode }
|
||||
}
|
||||
|
||||
fun getFormatForFilenameSelection(): ImageFormat = imageFormat
|
||||
|
||||
fun updateDrawLineStyle(style: DrawLineStyle) {
|
||||
_drawLineStyle.update { style }
|
||||
}
|
||||
|
||||
fun updateHelperGridParams(params: HelperGridParams) {
|
||||
_helperGridParams.update { params }
|
||||
}
|
||||
|
||||
@AssistedFactory
|
||||
fun interface Factory {
|
||||
operator fun invoke(
|
||||
componentContext: ComponentContext,
|
||||
initialUri: Uri?,
|
||||
onGoBack: () -> Unit,
|
||||
onNavigate: (Screen) -> Unit,
|
||||
): DrawComponent
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user