diff --git a/core/resources/src/main/res/values/strings.xml b/core/resources/src/main/res/values/strings.xml index a556d8e40..b65278f42 100644 --- a/core/resources/src/main/res/values/strings.xml +++ b/core/resources/src/main/res/values/strings.xml @@ -1497,4 +1497,6 @@ Enable Tonemapping Enter % Cannot access the site, try using VPN or check if the url correct + Markup Layers + Layers mode with ability to freely place images, text and more \ No newline at end of file diff --git a/core/ui/src/main/kotlin/ru/tech/imageresizershrinker/core/ui/utils/navigation/Screen.kt b/core/ui/src/main/kotlin/ru/tech/imageresizershrinker/core/ui/utils/navigation/Screen.kt index 30e27556e..752d7c9e9 100644 --- a/core/ui/src/main/kotlin/ru/tech/imageresizershrinker/core/ui/utils/navigation/Screen.kt +++ b/core/ui/src/main/kotlin/ru/tech/imageresizershrinker/core/ui/utils/navigation/Screen.kt @@ -36,6 +36,7 @@ import androidx.compose.material.icons.outlined.FolderZip import androidx.compose.material.icons.outlined.GifBox import androidx.compose.material.icons.outlined.Gradient import androidx.compose.material.icons.outlined.Grain +import androidx.compose.material.icons.outlined.Layers import androidx.compose.material.icons.outlined.Photo import androidx.compose.material.icons.outlined.PictureAsPdf import androidx.compose.material.icons.outlined.QrCode @@ -125,6 +126,7 @@ sealed class Screen( is NoiseGeneration -> "Noise_Generation" is CollageMaker -> "Collage_Maker" is LibrariesInfo -> "Libraries_Info" + is MarkupLayers -> "Markup Layers" } val icon: ImageVector? @@ -168,8 +170,16 @@ sealed class Screen( is WebpTools -> Icons.Rounded.WebpBox NoiseGeneration -> Icons.Outlined.Grain is CollageMaker -> Icons.Outlined.AutoAwesomeMosaic + is MarkupLayers -> Icons.Outlined.Layers //TODO: Icons for this and stacking } + @Serializable + data object LibrariesInfo : Screen( + id = -4, + title = 0, + subtitle = 0 + ) + @Serializable data object Settings : Screen( id = -3, @@ -780,10 +790,12 @@ sealed class Screen( ) @Serializable - data object LibrariesInfo : Screen( - id = -4, - title = 0, - subtitle = 0 + data class MarkupLayers( + val uri: KUri? = null + ) : Screen( + id = 34, + title = R.string.markup_layers, + subtitle = R.string.markup_layers_sub ) companion object { @@ -806,6 +818,7 @@ sealed class Screen( Filter(), Draw(), EraseBackground(), + MarkupLayers(), CollageMaker(), ImageStitching(), ImageStacking(), diff --git a/feature/markup-layers/.gitignore b/feature/markup-layers/.gitignore new file mode 100644 index 000000000..42afabfd2 --- /dev/null +++ b/feature/markup-layers/.gitignore @@ -0,0 +1 @@ +/build \ No newline at end of file diff --git a/feature/markup-layers/build.gradle.kts b/feature/markup-layers/build.gradle.kts new file mode 100644 index 000000000..259af43f5 --- /dev/null +++ b/feature/markup-layers/build.gradle.kts @@ -0,0 +1,25 @@ +/* + * ImageToolbox is an image editor for android + * Copyright (c) 2024 T8RIN (Malik Mukhametzyanov) + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + * You should have received a copy of the Apache License + * along with this program. If not, see . + */ + +plugins { + alias(libs.plugins.image.toolbox.library) + alias(libs.plugins.image.toolbox.feature) + alias(libs.plugins.image.toolbox.hilt) + alias(libs.plugins.image.toolbox.compose) +} + +android.namespace = "ru.tech.imageresizershrinker.feature.markup_layers" \ No newline at end of file diff --git a/feature/markup-layers/src/main/AndroidManifest.xml b/feature/markup-layers/src/main/AndroidManifest.xml new file mode 100644 index 000000000..44008a433 --- /dev/null +++ b/feature/markup-layers/src/main/AndroidManifest.xml @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/feature/markup-layers/src/main/java/ru/tech/imageresizershrinker/feature/markup_layers/presentation/MarkupLayersContent.kt b/feature/markup-layers/src/main/java/ru/tech/imageresizershrinker/feature/markup_layers/presentation/MarkupLayersContent.kt new file mode 100644 index 000000000..917074947 --- /dev/null +++ b/feature/markup-layers/src/main/java/ru/tech/imageresizershrinker/feature/markup_layers/presentation/MarkupLayersContent.kt @@ -0,0 +1,28 @@ +/* + * 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 . + */ + +package ru.tech.imageresizershrinker.feature.markup_layers.presentation + +import androidx.compose.runtime.Composable +import ru.tech.imageresizershrinker.feature.markup_layers.presentation.screenLogic.MarkupLayersComponent + +@Composable +fun MarkupLayersContent( + component: MarkupLayersComponent +) { + +} \ No newline at end of file diff --git a/feature/markup-layers/src/main/java/ru/tech/imageresizershrinker/feature/markup_layers/presentation/components/EditBox.kt b/feature/markup-layers/src/main/java/ru/tech/imageresizershrinker/feature/markup_layers/presentation/components/EditBox.kt new file mode 100644 index 000000000..d8546a75a --- /dev/null +++ b/feature/markup-layers/src/main/java/ru/tech/imageresizershrinker/feature/markup_layers/presentation/components/EditBox.kt @@ -0,0 +1,206 @@ +package ru.tech.imageresizershrinker.feature.markup_layers.presentation.components + +import androidx.compose.animation.core.Animatable +import androidx.compose.animation.core.InfiniteTransition +import androidx.compose.animation.core.LinearEasing +import androidx.compose.animation.core.RepeatMode +import androidx.compose.animation.core.animateFloat +import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.animation.core.infiniteRepeatable +import androidx.compose.animation.core.rememberInfiniteTransition +import androidx.compose.animation.core.tween +import androidx.compose.foundation.Canvas +import androidx.compose.foundation.background +import androidx.compose.foundation.gestures.detectTapGestures +import androidx.compose.foundation.gestures.rememberTransformableState +import androidx.compose.foundation.gestures.transformable +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.BoxScope +import androidx.compose.foundation.layout.BoxWithConstraintsScope +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.SideEffect +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.draw.clip +import androidx.compose.ui.draw.scale +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.PathEffect +import androidx.compose.ui.graphics.Shape +import androidx.compose.ui.graphics.drawOutline +import androidx.compose.ui.graphics.drawscope.Stroke +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.layout.onSizeChanged +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.unit.IntSize +import androidx.compose.ui.unit.dp + +@Composable +fun BoxWithConstraintsScope.EditBox( + onTap: () -> Unit, + modifier: Modifier = Modifier, + state: EditBoxState = remember { EditBoxState() }, + shape: Shape = RoundedCornerShape(4.dp), + content: @Composable BoxScope.() -> Unit +) { + val parentSize by remember(constraints) { + derivedStateOf { + IntSize( + constraints.maxWidth, + constraints.maxHeight + ) + } + } + EditBox( + modifier = modifier, + onTap = onTap, + state = state, + parentSize = parentSize, + shape = shape, + content = content + ) +} + +@Composable +fun EditBox( + onTap: () -> Unit, + parentSize: IntSize, + modifier: Modifier = Modifier, + state: EditBoxState = remember { EditBoxState() }, + shape: Shape = RoundedCornerShape(4.dp), + content: @Composable BoxScope.() -> Unit +) { + var contentSize by remember { + mutableStateOf(IntSize.Zero) + } + + val parentMaxWidth = parentSize.width + val parentMaxHeight = parentSize.height + + SideEffect { + state.canvasSize = parentSize + } + + val transformState = rememberTransformableState { zoomChange, offsetChange, rotationChange -> + state.applyChanges( + parentMaxWidth = parentMaxWidth, + parentMaxHeight = parentMaxHeight, + contentSize = contentSize, + zoomChange = zoomChange, + offsetChange = offsetChange, + rotationChange = rotationChange + ) + } + + val tapScale = remember { Animatable(1f) } + + LaunchedEffect(state.isActive) { + if (state.isActive) { + tapScale.animateTo(0.95f) + tapScale.animateTo(1.02f) + tapScale.animateTo(1f) + } + } + + val borderAlpha by animateFloatAsState(if (state.isActive) 1f else 0f) + Box( + modifier = modifier + .onSizeChanged { + contentSize = it + } + .graphicsLayer( + scaleX = state.scale, + scaleY = state.scale, + rotationZ = state.rotation, + translationX = state.offset.x, + translationY = state.offset.y + ) + .scale(tapScale.value) + .clip(shape) + .background(MaterialTheme.colorScheme.primary.copy(0.2f * borderAlpha)) + .pointerInput(onTap) { + detectTapGestures { + onTap() + } + } + .transformable( + state = transformState, + enabled = state.isActive + ), + contentAlignment = Alignment.Center + ) { + content() + AnimatedBorder( + modifier = Modifier.matchParentSize(), + alpha = borderAlpha, + scale = state.scale, + shape = shape + ) + Surface( + color = Color.Transparent, + modifier = Modifier.matchParentSize() + ) { } + } +} + +@Composable +internal fun AnimatedBorder( + modifier: Modifier, + alpha: Float, + scale: Float, + shape: Shape +) { + val transition: InfiniteTransition = rememberInfiniteTransition() + + // Infinite phase animation for PathEffect + val phase by transition.animateFloat( + initialValue = 0f, + targetValue = 80f, + animationSpec = infiniteRepeatable( + animation = tween( + durationMillis = 1000, + easing = LinearEasing + ), + repeatMode = RepeatMode.Restart + ) + ) + + val pathEffect = PathEffect.dashPathEffect( + intervals = floatArrayOf(20f, 20f), + phase = phase + ) + + val density = LocalDensity.current + val colorScheme = MaterialTheme.colorScheme + Canvas(modifier = modifier) { + val outline = shape.createOutline( + size = size, + layoutDirection = layoutDirection, + density = density + ) + drawOutline( + outline = outline, + color = colorScheme.primary.copy(alpha), + style = Stroke( + width = 3.dp.toPx() * (1f / scale) + ) + ) + drawOutline( + outline = outline, + color = colorScheme.primaryContainer.copy(alpha), + style = Stroke( + width = 3.dp.toPx() * (1f / scale), + pathEffect = pathEffect + ) + ) + } +} \ No newline at end of file diff --git a/feature/markup-layers/src/main/java/ru/tech/imageresizershrinker/feature/markup_layers/presentation/components/EditBoxState.kt b/feature/markup-layers/src/main/java/ru/tech/imageresizershrinker/feature/markup_layers/presentation/components/EditBoxState.kt new file mode 100644 index 000000000..56f80e2be --- /dev/null +++ b/feature/markup-layers/src/main/java/ru/tech/imageresizershrinker/feature/markup_layers/presentation/components/EditBoxState.kt @@ -0,0 +1,93 @@ +package ru.tech.imageresizershrinker.feature.markup_layers.presentation.components + +import androidx.compose.runtime.* +import androidx.compose.runtime.mutableStateOf +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.unit.IntSize +import androidx.compose.ui.util.fastCoerceIn +import kotlin.math.absoluteValue +import kotlin.math.cos +import kotlin.math.sin + +class EditBoxState( + scale: Float = 1f, + rotation: Float = 0f, + offset: Offset = Offset.Companion.Zero, + isActive: Boolean = false +) { + var isActive by mutableStateOf(isActive) + internal set + + fun activate() { + isActive = true + } + + fun deactivate() { + isActive = false + } + + internal fun applyChanges( + parentMaxWidth: Int, + parentMaxHeight: Int, + contentSize: IntSize, + zoomChange: Float, + offsetChange: Offset, + rotationChange: Float + ) { + rotation += rotationChange + scale = (scale * zoomChange).fastCoerceIn(0.5f, 10f) + val panChange = (offsetChange * scale).rotateBy(rotation) + + val extraWidth = (parentMaxWidth - contentSize.width * scale).absoluteValue + val extraHeight = (parentMaxHeight - contentSize.height * scale).absoluteValue + + val maxX = extraWidth / 2 + val maxY = extraHeight / 2 + + offset = Offset( + x = (offset.x + panChange.x).coerceIn(-maxX, maxX), + y = (offset.y + panChange.y).coerceIn(-maxY, maxY), + ) + } + + var scale by mutableFloatStateOf(scale) + internal set + + var rotation by mutableFloatStateOf(rotation) + internal set + + var offset by mutableStateOf(offset) + internal set + + private val IntSize.aspect: Float get() = width / height.toFloat() + + private val _canvasSize = mutableStateOf(IntSize.Companion.Zero) + + var canvasSize: IntSize + get() = _canvasSize.value + set(value) { + if (_canvasSize.value != IntSize.Companion.Zero && _canvasSize.value != value) { + val sx = value.width.toFloat() / _canvasSize.value.width + val sy = value.height.toFloat() / _canvasSize.value.height + if (_canvasSize.value.aspect < value.aspect) { + scale *= minOf(sx, sy) + offset *= minOf(sx, sy) + } else { + scale /= minOf(sx, sy) + offset /= minOf(sx, sy) + } + } + _canvasSize.value = value + } +} + +private fun Offset.rotateBy( + angle: Float +): Offset { + val angleInRadians = ROTATION_CONST * angle + val newX = x * cos(angleInRadians) - y * sin(angleInRadians) + val newY = x * sin(angleInRadians) + y * cos(angleInRadians) + return Offset(newX, newY) +} + +private const val ROTATION_CONST = (Math.PI / 180f).toFloat() \ No newline at end of file diff --git a/feature/markup-layers/src/main/java/ru/tech/imageresizershrinker/feature/markup_layers/presentation/screenLogic/MarkupLayersComponent.kt b/feature/markup-layers/src/main/java/ru/tech/imageresizershrinker/feature/markup_layers/presentation/screenLogic/MarkupLayersComponent.kt new file mode 100644 index 000000000..f4e20dbdd --- /dev/null +++ b/feature/markup-layers/src/main/java/ru/tech/imageresizershrinker/feature/markup_layers/presentation/screenLogic/MarkupLayersComponent.kt @@ -0,0 +1,48 @@ +/* + * 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 . + */ + +package ru.tech.imageresizershrinker.feature.markup_layers.presentation.screenLogic + +import android.net.Uri +import com.arkivanov.decompose.ComponentContext +import dagger.assisted.Assisted +import dagger.assisted.AssistedFactory +import dagger.assisted.AssistedInject +import ru.tech.imageresizershrinker.core.domain.dispatchers.DispatchersHolder +import ru.tech.imageresizershrinker.core.ui.utils.BaseComponent +import ru.tech.imageresizershrinker.core.ui.utils.navigation.Screen + + +class MarkupLayersComponent @AssistedInject internal constructor( + @Assisted componentContext: ComponentContext, + @Assisted initialUri: Uri?, + @Assisted val onGoBack: () -> Unit, + @Assisted val onNavigate: (Screen) -> Unit, + dispatchersHolder: DispatchersHolder +) : BaseComponent(dispatchersHolder, componentContext) { + + @AssistedFactory + fun interface Factory { + operator fun invoke( + componentContext: ComponentContext, + initialUri: Uri?, + onGoBack: () -> Unit, + onNavigate: (Screen) -> Unit, + ): MarkupLayersComponent + } + +} \ No newline at end of file diff --git a/feature/root/build.gradle.kts b/feature/root/build.gradle.kts index d2673432d..5fd5c03f5 100644 --- a/feature/root/build.gradle.kts +++ b/feature/root/build.gradle.kts @@ -63,4 +63,5 @@ dependencies { implementation(projects.feature.noiseGeneration) implementation(projects.feature.colllageMaker) implementation(projects.feature.librariesInfo) + implementation(projects.feature.markupLayers) } \ No newline at end of file diff --git a/feature/root/src/main/java/ru/tech/imageresizershrinker/feature/root/presentation/components/navigation/ChildProvider.kt b/feature/root/src/main/java/ru/tech/imageresizershrinker/feature/root/presentation/components/navigation/ChildProvider.kt index 3ac6e5108..a1016b0d6 100644 --- a/feature/root/src/main/java/ru/tech/imageresizershrinker/feature/root/presentation/components/navigation/ChildProvider.kt +++ b/feature/root/src/main/java/ru/tech/imageresizershrinker/feature/root/presentation/components/navigation/ChildProvider.kt @@ -43,6 +43,7 @@ import ru.tech.imageresizershrinker.feature.libraries_info.presentation.screenLo import ru.tech.imageresizershrinker.feature.limits_resize.presentation.screenLogic.LimitsResizeComponent import ru.tech.imageresizershrinker.feature.load_net_image.presentation.screenLogic.LoadNetImageComponent import ru.tech.imageresizershrinker.feature.main.presentation.screenLogic.MainComponent +import ru.tech.imageresizershrinker.feature.markup_layers.presentation.screenLogic.MarkupLayersComponent import ru.tech.imageresizershrinker.feature.pdf_tools.presentation.screenLogic.PdfToolsComponent import ru.tech.imageresizershrinker.feature.pick_color.presentation.screenLogic.PickColorFromImageComponent import ru.tech.imageresizershrinker.feature.recognize.text.presentation.screenLogic.RecognizeTextComponent @@ -98,7 +99,8 @@ internal class ChildProvider @Inject constructor( private val easterEggComponentFactory: EasterEggComponent.Factory, private val colorToolsComponentFactory: ColorToolsComponent.Factory, private val librariesInfoComponentFactory: LibrariesInfoComponent.Factory, - private val mainComponentFactory: MainComponent.Factory + private val mainComponentFactory: MainComponent.Factory, + private val markupLayersComponentFactory: MarkupLayersComponent.Factory ) { fun RootComponent.createChild( config: Screen, @@ -430,5 +432,14 @@ internal class ChildProvider @Inject constructor( onGoBack = ::navigateBack ) ) + + is Screen.MarkupLayers -> NavigationChild.MarkupLayers( + markupLayersComponentFactory( + componentContext = componentContext, + initialUri = config.uri, + onGoBack = ::navigateBack, + onNavigate = ::navigateTo + ) + ) } } \ No newline at end of file diff --git a/feature/root/src/main/java/ru/tech/imageresizershrinker/feature/root/presentation/components/navigation/NavigationChild.kt b/feature/root/src/main/java/ru/tech/imageresizershrinker/feature/root/presentation/components/navigation/NavigationChild.kt index 90323ad6a..321b0a7da 100644 --- a/feature/root/src/main/java/ru/tech/imageresizershrinker/feature/root/presentation/components/navigation/NavigationChild.kt +++ b/feature/root/src/main/java/ru/tech/imageresizershrinker/feature/root/presentation/components/navigation/NavigationChild.kt @@ -66,6 +66,8 @@ import ru.tech.imageresizershrinker.feature.load_net_image.presentation.LoadNetI import ru.tech.imageresizershrinker.feature.load_net_image.presentation.screenLogic.LoadNetImageComponent import ru.tech.imageresizershrinker.feature.main.presentation.MainContent import ru.tech.imageresizershrinker.feature.main.presentation.screenLogic.MainComponent +import ru.tech.imageresizershrinker.feature.markup_layers.presentation.MarkupLayersContent +import ru.tech.imageresizershrinker.feature.markup_layers.presentation.screenLogic.MarkupLayersComponent import ru.tech.imageresizershrinker.feature.pdf_tools.presentation.PdfToolsContent import ru.tech.imageresizershrinker.feature.pdf_tools.presentation.screenLogic.PdfToolsComponent import ru.tech.imageresizershrinker.feature.pick_color.presentation.PickColorFromImageContent @@ -292,4 +294,9 @@ internal sealed class NavigationChild { override fun Content() = LibrariesInfoContent(component) } + class MarkupLayers(val component: MarkupLayersComponent) : NavigationChild() { + @Composable + override fun Content() = MarkupLayersContent(component) + } + } \ No newline at end of file diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 7335a1220..4e423f128 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -3,8 +3,8 @@ androidMinSdk = "21" androidTargetSdk = "35" androidCompileSdk = "35" -versionName = "3.1.0" -versionCode = "159" +versionName = "3.2.0-alpha01" +versionCode = "160" jvmTarget = "17" @@ -75,6 +75,7 @@ zxingAndroidEmbedded = "4.3.0" capturable = "3.0.0" moshi = "1.15.1" aboutlibraries = "11.2.3" +junit = "4.13.2" [libraries] aboutlibraries-m3 = { module = "com.mikepenz:aboutlibraries-compose-m3", version.ref = "aboutlibraries" } @@ -193,6 +194,7 @@ androidx-material = { module = "androidx.compose.material:material", version.ref androidx-material3 = { module = "androidx.compose.material3:material3", version.ref = "material3" } desugaring = { module = "com.android.tools:desugar_jdk_libs", version.ref = "desugaring" } +junit = { group = "junit", name = "junit", version.ref = "junit" } [plugins] image-toolbox-library = { id = "image.toolbox.library", version = "unspecified" } diff --git a/settings.gradle.kts b/settings.gradle.kts index a9e970150..773cc877c 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -17,6 +17,9 @@ @file:Suppress("UnstableApiUsage") +include(":feature:markup-layers") + + include(":feature:libraries-info")