diff --git a/README.md b/README.md index 20f0cff..4b6ec80 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,4 @@ -# **ARK Android** +# ARK Android ARK Android contains a set of independent component libraries which can be used across ARK projects. diff --git a/build.gradle.kts b/build.gradle.kts index fa415a4..dc0adfc 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -4,4 +4,5 @@ plugins { id("org.jetbrains.kotlin.android") version "1.9.22" apply false id("com.android.library") version "8.2.2" apply false id("pl.allegro.tech.build.axion-release") version "1.17.0" apply false + id ("org.jetbrains.kotlin.plugin.serialization") version "1.8.21" apply false } \ No newline at end of file diff --git a/canvas/.gitignore b/canvas/.gitignore new file mode 100644 index 0000000..42afabf --- /dev/null +++ b/canvas/.gitignore @@ -0,0 +1 @@ +/build \ No newline at end of file diff --git a/canvas/build.gradle.kts b/canvas/build.gradle.kts new file mode 100644 index 0000000..9aff29c --- /dev/null +++ b/canvas/build.gradle.kts @@ -0,0 +1,79 @@ +plugins { + alias(libs.plugins.androidLibrary) + alias(libs.plugins.jetbrainsKotlinAndroid) + id("org.jetbrains.kotlin.plugin.serialization") version ("1.8.21") + id("kotlin-kapt") +} + +android { + namespace = "dev.arkbuilders.canvas" + compileSdk = 34 + + defaultConfig { + minSdk = 29 + + testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" + consumerProguardFiles("consumer-rules.pro") + } + + buildFeatures { + compose = true + } + + composeOptions { + kotlinCompilerExtensionVersion = "1.5.10" + } + buildTypes { + release { + isMinifyEnabled = false + proguardFiles( + getDefaultProguardFile("proguard-android-optimize.txt"), + "proguard-rules.pro" + ) + } + } + compileOptions { + sourceCompatibility = JavaVersion.VERSION_1_8 + targetCompatibility = JavaVersion.VERSION_1_8 + } + kotlinOptions { + jvmTarget = "1.8" + } +} + +dependencies { + + implementation(libs.androidx.core.ktx) + implementation(libs.androidx.appcompat) + implementation(libs.android.material) + implementation(libs.androidx.ui.android) + implementation(project(":filepicker")) + + val compose_version = "1.5.4" + implementation("androidx.compose.ui:ui:$compose_version") + implementation("androidx.compose.material:material:$compose_version") + implementation("androidx.compose.ui:ui-tooling-preview:$compose_version") + implementation("androidx.lifecycle:lifecycle-runtime-ktx:2.3.1") + implementation("androidx.activity:activity-compose:1.3.1") + implementation("com.jakewharton.timber:timber:5.0.1") + + + implementation("com.godaddy.android.colorpicker:compose-color-picker:0.7.0") + implementation("androidx.navigation:navigation-compose:2.5.2") + implementation("io.github.hokofly:hoko-blur:1.5.3") + + implementation("com.github.bumptech.glide:glide:4.16.0") + kapt("com.github.bumptech.glide:compiler:4.16.0") + implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.4.0") + + implementation("androidx.preference:preference-ktx:1.2.0") + + implementation("androidx.preference:preference:1.2.0'") + implementation("com.google.dagger:hilt-android:2.48") + kapt("com.google.dagger:hilt-compiler:2.48") + kapt("androidx.hilt:hilt-compiler:1.0.0") + + testImplementation(libs.junit) + androidTestImplementation(libs.androidx.test.junit) + androidTestImplementation(libs.androidx.test.espresso) +} \ No newline at end of file diff --git a/canvas/consumer-rules.pro b/canvas/consumer-rules.pro new file mode 100644 index 0000000..e69de29 diff --git a/canvas/proguard-rules.pro b/canvas/proguard-rules.pro new file mode 100644 index 0000000..481bb43 --- /dev/null +++ b/canvas/proguard-rules.pro @@ -0,0 +1,21 @@ +# Add project specific ProGuard rules here. +# You can control the set of applied configuration files using the +# proguardFiles setting in build.gradle. +# +# For more details, see +# http://developer.android.com/guide/developing/tools/proguard.html + +# If your project uses WebView with JS, uncomment the following +# and specify the fully qualified class name to the JavaScript interface +# class: +#-keepclassmembers class fqcn.of.javascript.interface.for.webview { +# public *; +#} + +# Uncomment this to preserve the line number information for +# debugging stack traces. +#-keepattributes SourceFile,LineNumberTable + +# If you keep the line number information, uncomment this to +# hide the original source file name. +#-renamesourcefileattribute SourceFile \ No newline at end of file diff --git a/canvas/src/androidTest/java/dev/arkbuilders/canvas/ExampleInstrumentedTest.kt b/canvas/src/androidTest/java/dev/arkbuilders/canvas/ExampleInstrumentedTest.kt new file mode 100644 index 0000000..d31254f --- /dev/null +++ b/canvas/src/androidTest/java/dev/arkbuilders/canvas/ExampleInstrumentedTest.kt @@ -0,0 +1,24 @@ +package dev.arkbuilders.canvas + +import androidx.test.platform.app.InstrumentationRegistry +import androidx.test.ext.junit.runners.AndroidJUnit4 + +import org.junit.Test +import org.junit.runner.RunWith + +import org.junit.Assert.* + +/** + * Instrumented test, which will execute on an Android device. + * + * See [testing documentation](http://d.android.com/tools/testing). + */ +@RunWith(AndroidJUnit4::class) +class ExampleInstrumentedTest { + @Test + fun useAppContext() { + // Context of the app under test. + val appContext = InstrumentationRegistry.getInstrumentation().targetContext + assertEquals("dev.arkbuilders.canvas.test", appContext.packageName) + } +} \ No newline at end of file diff --git a/canvas/src/main/AndroidManifest.xml b/canvas/src/main/AndroidManifest.xml new file mode 100644 index 0000000..a5918e6 --- /dev/null +++ b/canvas/src/main/AndroidManifest.xml @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/canvas/src/main/java/dev/arkbuilders/canvas/presentation/ArkCanvasFragment.kt b/canvas/src/main/java/dev/arkbuilders/canvas/presentation/ArkCanvasFragment.kt new file mode 100644 index 0000000..5ff31c1 --- /dev/null +++ b/canvas/src/main/java/dev/arkbuilders/canvas/presentation/ArkCanvasFragment.kt @@ -0,0 +1,109 @@ +package dev.arkbuilders.canvas.presentation + +import android.os.Build +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import androidx.annotation.RequiresApi +import androidx.compose.ui.platform.ComposeView +import androidx.compose.ui.platform.ViewCompositionStrategy +import androidx.fragment.app.Fragment +import dev.arkbuilders.canvas.R +import dev.arkbuilders.canvas.presentation.data.Preferences +import dev.arkbuilders.canvas.presentation.data.Resolution +import dev.arkbuilders.canvas.presentation.drawing.EditManager +import dev.arkbuilders.canvas.presentation.edit.EditScreen +import dev.arkbuilders.canvas.presentation.edit.EditViewModel +import dev.arkbuilders.canvas.presentation.resourceloader.BitmapResourceManager +import dev.arkbuilders.canvas.presentation.resourceloader.CanvasResourceManager +import dev.arkbuilders.canvas.presentation.resourceloader.SvgResourceManager +import java.nio.file.Path +import kotlin.io.path.Path + +private const val imagePath = "image_path_param" + +class ArkCanvasFragment : Fragment() { + private lateinit var imagePathParam: String + + private lateinit var prefs: Preferences + + private lateinit var viewModel: EditViewModel + private lateinit var bitmapResourceManager: CanvasResourceManager + private lateinit var svgResourceManager: CanvasResourceManager + + lateinit var editManager: EditManager + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + arguments?.let { + imagePathParam = it.getString(imagePath) ?: "" + } + val context = requireActivity().applicationContext + prefs = Preferences(appCtx = context) + editManager = EditManager() + bitmapResourceManager = BitmapResourceManager(context = context, editManager = editManager) + svgResourceManager = SvgResourceManager(editManager = editManager) + viewModel = EditViewModel( + primaryColor = 0xFF101828, + launchedFromIntent = false, + imagePath = pathFromString(), + imageUri = null, + maxResolution = Resolution(350, 720), + prefs = prefs, + editManager = editManager, + bitMapResourceManager = bitmapResourceManager, + svgResourceManager = svgResourceManager, + ) + } + + private fun pathFromString(): Path?{ + return if (imagePathParam.isEmpty()) { + null + } else { + Path(imagePathParam) + } + } + + override fun onCreateView( + inflater: LayoutInflater, container: ViewGroup?, + savedInstanceState: Bundle? + ): View? { + return inflater.inflate(R.layout.fragment_ark_canvas, container, false) + } + + @RequiresApi(Build.VERSION_CODES.UPSIDE_DOWN_CAKE) + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + val composeView = view.findViewById(R.id.compose_view) + + composeView.apply { + setViewCompositionStrategy( + ViewCompositionStrategy.DisposeOnViewTreeLifecycleDestroyed + ) + setContent { + // Set Content here + EditScreen( + imagePath = null, + imageUri = null, + fragmentManager = requireActivity().supportFragmentManager, + navigateBack = { requireActivity().supportFragmentManager.popBackStackImmediate() }, + launchedFromIntent = false, + maxResolution = Resolution(350, 720), + onSaveSvg = { /*TODO*/ }, + viewModel = viewModel + ) + } + } + } + + companion object { + @JvmStatic + fun newInstance(param1: String) = + ArkCanvasFragment().apply { + arguments = Bundle().apply { + putString(imagePath, param1) + } + } + } +} \ No newline at end of file diff --git a/canvas/src/main/java/dev/arkbuilders/canvas/presentation/data/ImageDefaults.kt b/canvas/src/main/java/dev/arkbuilders/canvas/presentation/data/ImageDefaults.kt new file mode 100644 index 0000000..351ff42 --- /dev/null +++ b/canvas/src/main/java/dev/arkbuilders/canvas/presentation/data/ImageDefaults.kt @@ -0,0 +1,23 @@ +package dev.arkbuilders.canvas.presentation.data + +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.unit.IntSize +import kotlinx.serialization.Serializable + +@Serializable +data class ImageDefaults( + val colorValue: ULong = Color.White.value, + val resolution: Resolution? = null +) + +@Serializable +data class Resolution( + val width: Int, + val height: Int +) { + fun toIntSize() = IntSize(this.width, this.height) + + companion object { + fun fromIntSize(intSize: IntSize) = Resolution(intSize.width, intSize.height) + } +} diff --git a/canvas/src/main/java/dev/arkbuilders/canvas/presentation/data/Preferences.kt b/canvas/src/main/java/dev/arkbuilders/canvas/presentation/data/Preferences.kt new file mode 100644 index 0000000..5b0736e --- /dev/null +++ b/canvas/src/main/java/dev/arkbuilders/canvas/presentation/data/Preferences.kt @@ -0,0 +1,93 @@ +package dev.arkbuilders.canvas.presentation.data + +import android.content.Context +import androidx.compose.ui.graphics.Color +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import kotlinx.serialization.decodeFromString +import kotlinx.serialization.encodeToString +import kotlinx.serialization.json.Json +import java.io.IOException +import java.nio.file.Files +import javax.inject.Inject +import javax.inject.Singleton +import kotlin.io.path.exists +import kotlin.io.path.readText +import kotlin.io.path.writeText +import kotlin.text.Charsets.UTF_8 + +@Singleton +class Preferences @Inject constructor(private val appCtx: Context) { + + suspend fun persistUsedColors( + colors: List + ) = withContext(Dispatchers.IO) { + try { + val colorsStorage = appCtx.filesDir.resolve(COLORS_STORAGE) + .toPath() + val lines = colors.map { it.value.toString() } + Files.write(colorsStorage, lines, UTF_8) + } catch (e: IOException) { + e.printStackTrace() + } + } + + suspend fun readUsedColors(): List { + val colors = mutableListOf() + withContext(Dispatchers.IO) { + + try { + val colorsStorage = appCtx + .filesDir + .resolve(COLORS_STORAGE) + .toPath() + + if (colorsStorage.exists()) { + Files.readAllLines(colorsStorage, UTF_8).forEach { line -> + val color = Color(line.toULong()) + colors.add(color) + } + } + } catch (e: IOException) { + e.printStackTrace() + } + } + return colors + } + + suspend fun persistDefaults(color: Color, resolution: Resolution) { + withContext(Dispatchers.IO) { + val defaultsStorage = appCtx.filesDir.resolve(DEFAULTS_STORAGE) + .toPath() + val defaults = ImageDefaults( + color.value, + resolution + ) + val json = Json { ignoreUnknownKeys = true } // Custom Json instance + val jsonString = json.encodeToString(ImageDefaults.serializer(), defaults) + defaultsStorage.writeText(jsonString, UTF_8) + } + } + + suspend fun readDefaults(): ImageDefaults { + var defaults = ImageDefaults() + try { + withContext(Dispatchers.IO) { + val defaultsStorage = appCtx.filesDir.resolve(DEFAULTS_STORAGE) + .toPath() + if (defaultsStorage.exists()) { + val jsonString = defaultsStorage.readText(UTF_8) + defaults = Json.decodeFromString(jsonString) + } + } + } catch (e: IOException) { + e.printStackTrace() + } + return defaults + } + + companion object { + private const val COLORS_STORAGE = "colors" + private const val DEFAULTS_STORAGE = "defaults" + } +} diff --git a/canvas/src/main/java/dev/arkbuilders/canvas/presentation/drawing/EditCanvas.kt b/canvas/src/main/java/dev/arkbuilders/canvas/presentation/drawing/EditCanvas.kt new file mode 100644 index 0000000..b793025 --- /dev/null +++ b/canvas/src/main/java/dev/arkbuilders/canvas/presentation/drawing/EditCanvas.kt @@ -0,0 +1,370 @@ +package dev.arkbuilders.canvas.presentation.drawing + +import android.graphics.Matrix +import android.graphics.PointF +import android.view.MotionEvent +import androidx.compose.foundation.Canvas +import androidx.compose.foundation.gestures.awaitFirstDown +import androidx.compose.foundation.gestures.calculatePan +import androidx.compose.foundation.gestures.calculateZoom +import androidx.compose.foundation.gestures.forEachGesture +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.size +import androidx.compose.runtime.Composable +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.ExperimentalComposeUiApi +import androidx.compose.ui.Modifier +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.geometry.Rect +import androidx.compose.ui.graphics.ClipOp +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.Paint +import androidx.compose.ui.graphics.Path +import androidx.compose.ui.graphics.asAndroidBitmap +import androidx.compose.ui.graphics.drawscope.drawIntoCanvas +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.graphics.nativeCanvas +import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.input.pointer.pointerInteropFilter +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.unit.toSize +import dev.arkbuilders.canvas.presentation.edit.EditViewModel +import dev.arkbuilders.canvas.presentation.edit.TransparencyChessBoardCanvas +import dev.arkbuilders.canvas.presentation.edit.crop.CropWindow.Companion.computeDeltaX +import dev.arkbuilders.canvas.presentation.edit.crop.CropWindow.Companion.computeDeltaY +import dev.arkbuilders.canvas.presentation.picker.toDp +import dev.arkbuilders.canvas.presentation.utils.SVGCommand +import dev.arkbuilders.canvas.presentation.utils.calculateRotationFromOneFingerGesture + +@Composable +fun EditCanvas(viewModel: EditViewModel) { + val editManager = viewModel.editManager + var scale by remember { mutableStateOf(1f) } + var offset by remember { mutableStateOf(Offset.Zero) } + + fun resetScaleAndTranslate() { + editManager.apply { + if ( + isRotateMode.value || isCropMode.value || isResizeMode.value || + isBlurMode.value + ) { + scale = 1f; zoomScale = scale; offset = Offset.Zero + } + } + } + + Box(contentAlignment = Alignment.Center) { + val modifier = Modifier + .size( + editManager.availableDrawAreaSize.value.width.toDp(), + editManager.availableDrawAreaSize.value.height.toDp() + ) + .graphicsLayer { + resetScaleAndTranslate() + + // Eraser leaves black line instead of erasing without this hack, it uses BlendMode.SrcOut + // https://stackoverflow.com/questions/65653560/jetpack-compose-applying-porterduffmode-to-image + // Provide a slight opacity to for compositing into an + // offscreen buffer to ensure blend modes are applied to empty pixel information + // By default any alpha != 1.0f will use a compositing layer by default + alpha = 0.99f + + scaleX = scale + scaleY = scale + translationX = offset.x + translationY = offset.y + } + TransparencyChessBoardCanvas(modifier, editManager) + BackgroundCanvas(modifier, editManager) + DrawCanvas(modifier, viewModel) + } + if ( + editManager.isRotateMode.value || editManager.isZoomMode.value || + editManager.isPanMode.value + ) { + Canvas( + Modifier + .fillMaxSize() + .pointerInput(Any()) { + forEachGesture { + awaitPointerEventScope { + awaitFirstDown() + do { + val event = awaitPointerEvent() + when (true) { + (editManager.isRotateMode.value) -> { + val angle = event + .calculateRotationFromOneFingerGesture( + editManager.calcCenter() + ) + editManager.rotate(angle) + editManager.invalidatorTick.value++ + } + + else -> { + if (editManager.isZoomMode.value) { + scale *= event.calculateZoom() + editManager.zoomScale = scale + } + if (editManager.isPanMode.value) { + val pan = event.calculatePan() + offset = Offset( + offset.x + pan.x, + offset.y + pan.y + ) + } + } + } + } while (event.changes.any { it.pressed }) + } + } + } + ) {} + } +} + +@Composable +fun BackgroundCanvas(modifier: Modifier, editManager: EditManager) { + Canvas(modifier) { + editManager.apply { + invalidatorTick.value + var matrix = matrix + if ( + isCropMode.value || isRotateMode.value || + isResizeMode.value || isBlurMode.value + ) + matrix = editMatrix + drawIntoCanvas { canvas -> + backgroundImage.value?.let { + canvas.nativeCanvas.drawBitmap( + it.asAndroidBitmap(), + matrix, + null + ) + } ?: run { + val rect = Rect( + Offset.Zero, + imageSize.toSize() + ) + canvas.nativeCanvas.setMatrix(matrix) + canvas.drawRect(rect, backgroundPaint) + canvas.clipRect(rect, ClipOp.Intersect) + } + } + } + } +} + +@OptIn(ExperimentalComposeUiApi::class) +@Composable +fun DrawCanvas(modifier: Modifier, viewModel: EditViewModel) { + val context = LocalContext.current + val editManager = viewModel.editManager + var path = Path() + val currentPoint = PointF(0f, 0f) + val drawModifier = if (editManager.isCropMode.value) Modifier.fillMaxSize() + else modifier + + fun handleDrawEvent(action: Int, eventX: Float, eventY: Float) { + when (action) { + MotionEvent.ACTION_DOWN -> { + path.reset() + path.moveTo(eventX, eventY) + currentPoint.x = eventX + currentPoint.y = eventY + editManager.apply { + drawOperation.draw(path) + applyOperation() + + svg.apply { + val svgCommand = SVGCommand.MoveTo(eventX, eventY).apply { + paintColor = editManager.currentPaint.color.value + brushSize = editManager.currentPaint.strokeWidth + } + addCommand(svgCommand) + } + } + + } + MotionEvent.ACTION_MOVE -> { + path.quadraticTo( + currentPoint.x, + currentPoint.y, + (eventX + currentPoint.x) / 2, + (eventY + currentPoint.y) / 2 + ) + editManager.apply { + svg.apply { + addCommand( + SVGCommand.AbsQuadTo( + currentPoint.x, + currentPoint.y, + (eventX + currentPoint.x) / 2, + (eventY + currentPoint.y) / 2 + ).apply { + paintColor = editManager.currentPaint.color.value + brushSize = editManager.currentPaint.strokeWidth + }) + } + } + + currentPoint.x = eventX + currentPoint.y = eventY + } + MotionEvent.ACTION_CANCEL, MotionEvent.ACTION_UP -> { + // draw a dot + if (eventX == currentPoint.x && + eventY == currentPoint.y + ) { + path.lineTo(currentPoint.x, currentPoint.y) + } + editManager.clearRedoPath() + editManager.updateRevised() + path = Path() + } + + else -> {} + } + editManager.svg.addPath( + DrawPath( + path = path, + paint = editManager.currentPaint + ) + ) + } + + fun handleCropEvent(action: Int, eventX: Float, eventY: Float) { + when (action) { + MotionEvent.ACTION_DOWN -> { + currentPoint.x = eventX + currentPoint.y = eventY + editManager.cropWindow.detectTouchedSide( + Offset(eventX, eventY) + ) + } + MotionEvent.ACTION_MOVE -> { + val deltaX = + computeDeltaX(currentPoint.x, eventX) + val deltaY = + computeDeltaY(currentPoint.y, eventY) + + editManager.cropWindow.setDelta( + Offset( + deltaX, + deltaY + ) + ) + currentPoint.x = eventX + currentPoint.y = eventY + } + } + } + + fun handleEyeDropEvent(action: Int, eventX: Float, eventY: Float) { + viewModel.applyEyeDropper(action, eventX.toInt(), eventY.toInt()) + } + + fun handleBlurEvent(action: Int, eventX: Float, eventY: Float) { + when (action) { + MotionEvent.ACTION_DOWN -> { + currentPoint.x = eventX + currentPoint.y = eventY + } + MotionEvent.ACTION_MOVE -> { + val position = Offset( + currentPoint.x, + currentPoint.y + ) + val delta = Offset( + computeDeltaX(currentPoint.x, eventX), + computeDeltaY(currentPoint.y, eventY) + ) + editManager.blurOperation.move(position, delta) + currentPoint.x = eventX + currentPoint.y = eventY + } + else -> {} + } + } + + Canvas( + modifier = drawModifier.pointerInteropFilter { event -> + val eventX = event.x + val eventY = event.y + val tmpMatrix = Matrix() + editManager.matrix.invert(tmpMatrix) + val mappedXY = floatArrayOf( + event.x / editManager.zoomScale, + event.y / editManager.zoomScale + ) + tmpMatrix.mapPoints(mappedXY) + val mappedX = mappedXY[0] + val mappedY = mappedXY[1] + + when (true) { + editManager.isResizeMode.value -> {} + editManager.isBlurMode.value -> handleBlurEvent( + event.action, + eventX, + eventY + ) + + editManager.isCropMode.value -> handleCropEvent( + event.action, + eventX, + eventY + ) + + editManager.isEyeDropperMode.value -> handleEyeDropEvent( + event.action, + event.x, + event.y + ) + + else -> handleDrawEvent(event.action, mappedX, mappedY) + } + editManager.invalidatorTick.value++ + true + } + ) { + // force recomposition on invalidatorTick change + editManager.invalidatorTick.value + drawIntoCanvas { canvas -> + editManager.apply { + var matrix = this.matrix + if (isRotateMode.value || isResizeMode.value || isBlurMode.value) + matrix = editMatrix + if (isCropMode.value) matrix = Matrix() + canvas.nativeCanvas.setMatrix(matrix) + if (isResizeMode.value) return@drawIntoCanvas + if (isBlurMode.value) { + editManager.blurOperation.draw(context, canvas) + return@drawIntoCanvas + } + if (isCropMode.value) { + editManager.cropWindow.show(canvas) + return@drawIntoCanvas + } + val rect = Rect( + Offset.Zero, + imageSize.toSize() + ) + canvas.drawRect( + rect, + Paint().also { it.color = Color.Transparent } + ) + canvas.clipRect(rect, ClipOp.Intersect) + if (drawPaths.isNotEmpty()) { + drawPaths.forEach { + canvas.drawPath(it.path, it.paint) + } + } + } + } + } +} diff --git a/canvas/src/main/java/dev/arkbuilders/canvas/presentation/drawing/EditManager.kt b/canvas/src/main/java/dev/arkbuilders/canvas/presentation/drawing/EditManager.kt new file mode 100644 index 0000000..f926ec3 --- /dev/null +++ b/canvas/src/main/java/dev/arkbuilders/canvas/presentation/drawing/EditManager.kt @@ -0,0 +1,990 @@ +package dev.arkbuilders.canvas.presentation.drawing + +import android.graphics.Bitmap +import android.graphics.Matrix +import androidx.compose.runtime.MutableState +import androidx.compose.runtime.State +import androidx.compose.runtime.mutableStateOf +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.geometry.Rect +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.ImageBitmapConfig +import androidx.compose.ui.graphics.Paint +import androidx.compose.ui.graphics.PaintingStyle +import androidx.compose.ui.graphics.Path +import androidx.compose.ui.graphics.StrokeCap +import androidx.compose.ui.graphics.StrokeJoin +import androidx.compose.ui.graphics.asAndroidBitmap +import androidx.compose.ui.graphics.asImageBitmap +import androidx.compose.ui.graphics.nativeCanvas +import androidx.compose.ui.unit.IntSize +import androidx.compose.ui.unit.toSize +import dev.arkbuilders.canvas.presentation.data.ImageDefaults +import dev.arkbuilders.canvas.presentation.data.Resolution +import dev.arkbuilders.canvas.presentation.edit.Operation +import dev.arkbuilders.canvas.presentation.edit.blur.BlurOperation +import dev.arkbuilders.canvas.presentation.edit.crop.CropOperation +import dev.arkbuilders.canvas.presentation.edit.crop.CropWindow +import dev.arkbuilders.canvas.presentation.edit.draw.DrawOperation +import dev.arkbuilders.canvas.presentation.edit.resize.ResizeOperation +import dev.arkbuilders.canvas.presentation.edit.rotate.RotateOperation +import dev.arkbuilders.canvas.presentation.utils.SVG +import timber.log.Timber +import java.util.Stack +import kotlin.system.measureTimeMillis + +object ArkColorPalette { + val primary: Color = Color.Green + +} +class EditManager{ + private val drawPaint: MutableState = mutableStateOf(defaultPaint()) + + private val _paintColor: MutableState = + mutableStateOf(drawPaint.value.color) + val svg = SVG() + val paintColor: State = _paintColor + private val _backgroundColor = mutableStateOf(Color.Transparent) + val backgroundColor: State = _backgroundColor + + private val erasePaint: Paint = Paint().apply { + shader = null + color = backgroundColor.value + style = PaintingStyle.Stroke + blendMode = BlendMode.SrcOut + } + + val backgroundPaint: Paint + get() { + return Paint().apply { + color = backgroundImage.value?.let { + Color.Transparent + } ?: backgroundColor.value + } + } + + val blurIntensity = mutableStateOf(12f) + + val cropWindow = CropWindow(this) + + val drawOperation = DrawOperation(this) + val resizeOperation = ResizeOperation(this) + val rotateOperation = RotateOperation(this) + val cropOperation = CropOperation(this) + val blurOperation = BlurOperation(this) + + val currentPaint: Paint + get() = when (true) { + isEraseMode.value -> erasePaint + else -> drawPaint.value + } + + val drawPaths = Stack() + + val redoPaths = Stack() + + val backgroundImage = mutableStateOf(null) + val backgroundImage2 = mutableStateOf(null) + private val originalBackgroundImage = mutableStateOf(null) + + val matrix = Matrix() + val editMatrix = Matrix() + val backgroundMatrix = Matrix() + val rectMatrix = Matrix() + + private val matrixScale = mutableStateOf(1f) + var zoomScale = 1f + lateinit var bitmapScale: ResizeOperation.Scale + private set + + val imageSize: IntSize + get() { + return if (isResizeMode.value) + backgroundImage2.value?.let { + IntSize(it.width, it.height) + } ?: originalBackgroundImage.value?.let { + IntSize(it.width, it.height) + } ?: resolution.value?.toIntSize()!! + else + backgroundImage.value?.let { + IntSize(it.width, it.height) + } ?: resolution.value?.toIntSize() ?: drawAreaSize.value + } + + private val _resolution = mutableStateOf(null) + val resolution: State = _resolution + var drawAreaSize = mutableStateOf(IntSize.Zero) + val availableDrawAreaSize = mutableStateOf(IntSize.Zero) + + var invalidatorTick = mutableStateOf(0) + + private val _isEraseMode: MutableState = mutableStateOf(false) + val isEraseMode: State = _isEraseMode + + private val _canUndo: MutableState = mutableStateOf(false) + val canUndo: State = _canUndo + + private val _canRedo: MutableState = mutableStateOf(false) + val canRedo: State = _canRedo + + private val _isRotateMode = mutableStateOf(false) + val isRotateMode: State = _isRotateMode + + private val _isResizeMode = mutableStateOf(false) + val isResizeMode: State = _isResizeMode + + private val _isEyeDropperMode = mutableStateOf(false) + val isEyeDropperMode: State = _isEyeDropperMode + + private val _isBlurMode = mutableStateOf(false) + val isBlurMode: State = _isBlurMode + + private val _isZoomMode = mutableStateOf(false) + val isZoomMode: State = _isZoomMode + private val _isPanMode = mutableStateOf(false) + val isPanMode: State = _isPanMode + + val rotationAngle = mutableStateOf(0F) + var prevRotationAngle = 0f + + private val editedPaths = Stack>() + + val redoResize = Stack() + val resizes = Stack() + val rotationAngles = Stack() + val redoRotationAngles = Stack() + + private val undoStack = Stack() + private val redoStack = Stack() + + private val _isCropMode = mutableStateOf(false) + val isCropMode = _isCropMode + + val cropStack = Stack() + val redoCropStack = Stack() + + fun applyOperation() { + val operation: Operation = + when (true) { + isRotateMode.value -> rotateOperation + isCropMode.value -> cropOperation + isBlurMode.value -> blurOperation + isResizeMode.value -> resizeOperation + else -> drawOperation + } + operation.apply() + } + + + private fun undoOperation(operation: Operation) { + operation.undo() + } + + private fun redoOperation(operation: Operation) { + operation.redo() + } + + fun scaleToFit() { + val viewParams = backgroundImage.value?.let { + fitImage( + it, + drawAreaSize.value.width, + drawAreaSize.value.height + ) + } ?: run { + fitBackground( + imageSize, + drawAreaSize.value.width, + drawAreaSize.value.height + ) + } + matrixScale.value = viewParams.scale.x + scaleMatrix(viewParams) + updateAvailableDrawArea(viewParams.drawArea) + val bitmapXScale = + imageSize.width.toFloat() / viewParams.drawArea.width.toFloat() + val bitmapYScale = + imageSize.height.toFloat() / viewParams.drawArea.height.toFloat() + bitmapScale = ResizeOperation.Scale( + bitmapXScale, + bitmapYScale + ) + } + + fun scaleToFitOnEdit( + maxWidth: Int = drawAreaSize.value.width, + maxHeight: Int = drawAreaSize.value.height + ): ImageViewParams { + val viewParams = backgroundImage.value?.let { + fitImage(it, maxWidth, maxHeight) + } ?: run { + fitBackground( + imageSize, + maxWidth, + maxHeight + ) + } + scaleEditMatrix(viewParams) + updateAvailableDrawArea(viewParams.drawArea) + return viewParams + } + + private fun scaleMatrix(viewParams: ImageViewParams) { + matrix.setScale(viewParams.scale.x, viewParams.scale.y) + backgroundMatrix.setScale(viewParams.scale.x, viewParams.scale.y) + if (prevRotationAngle != 0f) { + val centerX = viewParams.drawArea.width / 2f + val centerY = viewParams.drawArea.height / 2f + matrix.postRotate(prevRotationAngle, centerX, centerY) + } + } + + private fun scaleEditMatrix(viewParams: ImageViewParams) { + editMatrix.setScale(viewParams.scale.x, viewParams.scale.y) + backgroundMatrix.setScale(viewParams.scale.x, viewParams.scale.y) + if (prevRotationAngle != 0f && isRotateMode.value) { + val centerX = viewParams.drawArea.width / 2f + val centerY = viewParams.drawArea.height / 2f + editMatrix.postRotate(prevRotationAngle, centerX, centerY) + } + } + + fun setBackgroundColor(color: Color) { + _backgroundColor.value = color + } + + fun setImageResolution(value: Resolution) { + _resolution.value = value + } + + fun initDefaults(defaults: ImageDefaults, maxResolution: Resolution) { + defaults.resolution?.let { + _resolution.value = it + } + if (resolution.value == null) + _resolution.value = maxResolution + _backgroundColor.value = Color(defaults.colorValue) + } + + fun getEditedImage(): ImageBitmap { + val size = this.imageSize + var bitmap = ImageBitmap( + size.width, + size.height, + ImageBitmapConfig.Argb8888 + ) + var pathBitmap: ImageBitmap? = null + val time = measureTimeMillis { + val matrix = Matrix() + if (this.drawPaths.isNotEmpty()) { + pathBitmap = ImageBitmap( + size.width, + size.height, + ImageBitmapConfig.Argb8888 + ) + val pathCanvas = Canvas(pathBitmap!!) + this.drawPaths.forEach { + pathCanvas.drawPath(it.path, it.paint) + } + } + backgroundImage.value?.let { + val canvas = Canvas(bitmap) + if (prevRotationAngle == 0f && drawPaths.isEmpty()) { + bitmap = it + return@let + } + if (prevRotationAngle != 0f) { + val centerX = size.width / 2f + val centerY = size.height / 2f + matrix.setRotate(prevRotationAngle, centerX, centerY) + } + canvas.nativeCanvas.drawBitmap( + it.asAndroidBitmap(), + matrix, + null + ) + if (drawPaths.isNotEmpty()) { + canvas.nativeCanvas.drawBitmap( + pathBitmap?.asAndroidBitmap()!!, + matrix, + null + ) + } + } ?: run { + val canvas = Canvas(bitmap) + if (prevRotationAngle != 0f) { + val centerX = size.width / 2 + val centerY = size.height / 2 + matrix.setRotate( + prevRotationAngle, + centerX.toFloat(), + centerY.toFloat() + ) + canvas.nativeCanvas.setMatrix(matrix) + } + canvas.drawRect( + Rect(Offset.Zero, size.toSize()), + backgroundPaint + ) + if (drawPaths.isNotEmpty()) { + canvas.drawImage( + pathBitmap!!, + Offset.Zero, + Paint() + ) + } + } + + } + Timber.tag("edit-viewmodel: getEditedImage").d( + "processing edits took ${time / 1000} s ${time % 1000} ms" + ) + return bitmap + } + + fun enterCropMode() { + toggleCropMode() + if (_isCropMode.value) { + val bitmap = getEditedImage() + setBackgroundImage2() + backgroundImage.value = bitmap + this.cropWindow.init( + bitmap.asAndroidBitmap() + ) + return + } + cancelCropMode() + scaleToFit() + cropWindow.close() + } + + fun enterRotateMode() { + toggleRotateMode() + if (isRotateMode.value) { + setBackgroundImage2() + scaleToFitOnEdit() + return + } + cancelRotateMode() + scaleToFit() + } + + fun updateAvailableDrawAreaByMatrix() { + val drawArea = backgroundImage.value?.let { + val drawWidth = it.width * matrixScale.value + val drawHeight = it.height * matrixScale.value + IntSize( + drawWidth.toInt(), + drawHeight.toInt() + ) + } ?: run { + val drawWidth = resolution.value?.width!! * matrixScale.value + val drawHeight = resolution.value?.height!! * matrixScale.value + IntSize( + drawWidth.toInt(), + drawHeight.toInt() + ) + } + updateAvailableDrawArea(drawArea) + } + + fun updateAvailableDrawArea(bitmap: ImageBitmap? = backgroundImage.value) { + if (bitmap == null) { + resolution.value?.let { + availableDrawAreaSize.value = it.toIntSize() + } + return + } + availableDrawAreaSize.value = IntSize( + bitmap.width, + bitmap.height + ) + } + + fun updateAvailableDrawArea(area: IntSize) { + availableDrawAreaSize.value = area + } + + internal fun clearRedoPath() { + redoPaths.clear() + } + + fun toggleEyeDropper() { + _isEyeDropperMode.value = !isEyeDropperMode.value + } + + fun updateRevised() { + _canUndo.value = undoStack.isNotEmpty() + _canRedo.value = redoStack.isNotEmpty() + } + + fun resizeDown(width: Int = 0, height: Int = 0) = + resizeOperation.resizeDown(width, height) { + backgroundImage.value = it + } + + fun rotate(angle: Float) { + val centerX = availableDrawAreaSize.value.width / 2 + val centerY = availableDrawAreaSize.value.height / 2 + if (isRotateMode.value) { + rotationAngle.value += angle + rotateOperation.rotate( + editMatrix, + angle, + centerX.toFloat(), + centerY.toFloat() + ) + return + } + rotateOperation.rotate( + matrix, + angle, + centerX.toFloat(), + centerY.toFloat() + ) + } + + fun addRotation() { + if (canRedo.value) clearRedo() + rotationAngles.add(prevRotationAngle) + undoStack.add(ROTATE) + prevRotationAngle = rotationAngle.value + updateRevised() + } + + private fun addAngle() { + rotationAngles.add(prevRotationAngle) + } + + fun addResize() { + if (canRedo.value) clearRedo() + resizes.add(backgroundImage2.value) + undoStack.add(RESIZE) + keepEditedPaths() + updateRevised() + } + + fun keepEditedPaths() { + val stack = Stack() + if (drawPaths.isNotEmpty()) { + val size = drawPaths.size + for (i in 1..size) { + stack.push(drawPaths.pop()) + } + } + editedPaths.add(stack) + } + + fun redrawEditedPaths() { + if (editedPaths.isNotEmpty()) { + val paths = editedPaths.pop() + if (paths.isNotEmpty()) { + val size = paths.size + for (i in 1..size) { + drawPaths.push(paths.pop()) + } + } + } + } + + fun addCrop() { + if (canRedo.value) clearRedo() + cropStack.add(backgroundImage2.value) + undoStack.add(CROP) + updateRevised() + } + + fun addBlur() { + if (canRedo.value) clearRedo() + undoStack.add(BLUR) + updateRevised() + } + + private fun operationByTask(task: String) = when (task) { + ROTATE -> rotateOperation + RESIZE -> resizeOperation + CROP -> cropOperation + BLUR -> blurOperation + else -> drawOperation + } + + fun isEligibleForUndoOrRedo(): Boolean = ( + !_isRotateMode.value && + !_isResizeMode.value && + !_isCropMode.value && + !_isEyeDropperMode.value && + !_isBlurMode.value + ) + + fun isEligibleForCropOrRotate(): Boolean { + return ( + !_isCropMode.value && + !_isResizeMode.value && + !_isEyeDropperMode.value && + !_isEraseMode.value && + !_isBlurMode.value + ) + } + + fun isEligibleForStrokeExpandOrErase(): Boolean = ( + !_isRotateMode.value && + !_isCropMode.value && + !_isResizeMode.value && + !_isEyeDropperMode.value && + !_isBlurMode.value + ) + + fun isEligibleForPanOrZoomMode(): Boolean = ( + !_isRotateMode.value && + !_isResizeMode.value && + !_isCropMode.value && + !_isEyeDropperMode.value && + !_isBlurMode.value && + !_isEraseMode.value + ) + + fun shouldApplyOperation(): Boolean = ( + _isCropMode.value || + _isRotateMode.value || + _isResizeMode.value || + _isBlurMode.value + ) + + fun isControlsDisabled(): Boolean = ( + !_isRotateMode.value && + !_isResizeMode.value && + !_isCropMode.value && + !_isEyeDropperMode.value + ) + + fun shouldCancelOperation(): Boolean = ( + _isCropMode.value || + _isRotateMode.value || + _isResizeMode.value || + _isEyeDropperMode.value || + _isBlurMode.value + ) + + fun isEligibleForBlurMode() = ( + !_isRotateMode.value && + !_isCropMode.value && + !_isEyeDropperMode.value && + !_isResizeMode.value && + !_isEraseMode.value + ) + + fun isEligibleForResizeMode() = ( + !_isRotateMode.value && + !_isCropMode.value && + !_isEyeDropperMode.value && + !_isEraseMode.value && + !_isBlurMode.value + ) + + fun shouldExpandColorDialog() = ( + !_isRotateMode.value && + !_isResizeMode.value && + !_isCropMode.value && + !_isEraseMode.value && + !_isBlurMode.value + ) + + fun undo() { + if (canUndo.value) { + val undoTask = undoStack.pop() + redoStack.push(undoTask) + undoOperation(operationByTask(undoTask)) + } + invalidatorTick.value++ + updateRevised() + } + + fun redo() { + if (canRedo.value) { + val redoTask = redoStack.pop() + undoStack.push(redoTask) + redoOperation(operationByTask(redoTask)) + invalidatorTick.value++ + updateRevised() + } + } + + fun saveRotationAfterOtherOperation() { + addAngle() + resetRotation() + } + + fun restoreRotationAfterUndoOtherOperation() { + if (rotationAngles.isNotEmpty()) { + prevRotationAngle = rotationAngles.pop() + rotationAngle.value = prevRotationAngle + } + } + + fun addDrawPath(path: Path) { + drawPaths.add( + DrawPath( + path, + currentPaint.copy().apply { + strokeWidth = drawPaint.value.strokeWidth + } + ) + ) + if (canRedo.value) clearRedo() + undoStack.add(DRAW) + } + + fun setPaintColor(color: Color) { + drawPaint.value.color = color + _paintColor.value = color + } + + private fun clearPaths() { + drawPaths.clear() + redoPaths.clear() + invalidatorTick.value++ + updateRevised() + } + + private fun clearResizes() { + resizes.clear() + redoResize.clear() + updateRevised() + } + + private fun resetRotation() { + rotationAngle.value = 0f + prevRotationAngle = 0f + } + + private fun clearRotations() { + rotationAngles.clear() + redoRotationAngles.clear() + resetRotation() + } + + fun clearEdits() { + if (_isRotateMode.value || _isResizeMode.value || _isCropMode.value || _isEyeDropperMode.value) return + clearPaths() + clearResizes() + clearRotations() + clearCrop() + blurOperation.clear() + undoStack.clear() + redoStack.clear() + restoreOriginalBackgroundImage() + scaleToFit() + updateRevised() + } + + private fun clearRedo() { + redoPaths.clear() + redoCropStack.clear() + redoRotationAngles.clear() + redoResize.clear() + redoStack.clear() + updateRevised() + } + + private fun clearCrop() { + cropStack.clear() + redoCropStack.clear() + updateRevised() + } + + fun setBackgroundImage2() { + backgroundImage2.value = backgroundImage.value + } + + fun redrawBackgroundImage2() { + backgroundImage.value = backgroundImage2.value + } + + fun setOriginalBackgroundImage(imgBitmap: ImageBitmap?) { + originalBackgroundImage.value = imgBitmap + } + + private fun restoreOriginalBackgroundImage() { + backgroundImage.value = originalBackgroundImage.value + updateAvailableDrawArea() + } + + fun toggleEraseMode() { + _isEraseMode.value = !isEraseMode.value + } + + fun toggleRotateMode() { + _isRotateMode.value = !isRotateMode.value + if (isRotateMode.value) editMatrix.set(matrix) + } + + fun toggleCropMode() { + _isCropMode.value = !isCropMode.value + if (!isCropMode.value) cropWindow.close() + } + + fun toggleZoomMode() { + _isZoomMode.value = !isZoomMode.value + } + + fun togglePanMode() { + _isPanMode.value = !isPanMode.value + } + + fun cancelCropMode() { + backgroundImage.value = backgroundImage2.value + editMatrix.reset() + } + + fun cancelRotateMode() { + rotationAngle.value = prevRotationAngle + editMatrix.reset() + } + + fun toggleResizeMode() { + _isResizeMode.value = !isResizeMode.value + } + + fun enterResizeMode() { + if (!isEligibleForResizeMode()) + return + toggleResizeMode() + if (isResizeMode.value) { + setBackgroundImage2() + val imgBitmap = getEditedImage() + backgroundImage.value = imgBitmap + resizeOperation.init( + imgBitmap.asAndroidBitmap() + ) + return + } + cancelResizeMode() + scaleToFit() + } + + fun cancelResizeMode() { + backgroundImage.value = backgroundImage2.value + editMatrix.reset() + } + + fun toggleBlurMode() { + _isBlurMode.value = !isBlurMode.value + } + + fun enterBlurMode(strokeSliderExpanded: Boolean) { + if (isEligibleForBlurMode() && !strokeSliderExpanded) toggleBlurMode() + if (isBlurMode.value) { + setBackgroundImage2() + backgroundImage.value = getEditedImage() + blurOperation.init() + return + } + blurOperation.cancel() + scaleToFit() + } + + fun setPaintStrokeWidth(strokeWidth: Float) { + drawPaint.value.strokeWidth = strokeWidth + } + + fun calcImageOffset(): Offset { + val drawArea = drawAreaSize.value + val allowedArea = availableDrawAreaSize.value + val xOffset = ((drawArea.width - allowedArea.width) / 2f) + .coerceAtLeast(0f) + val yOffset = ((drawArea.height - allowedArea.height) / 2f) + .coerceAtLeast(0f) + return Offset(xOffset, yOffset) + } + + fun calcCenter() = Offset( + availableDrawAreaSize.value.width / 2f, + availableDrawAreaSize.value.height / 2f + ) + + + fun resize( + imageBitmap: ImageBitmap, + maxWidth: Int, + maxHeight: Int + ): ImageBitmap { + val bitmap = imageBitmap.asAndroidBitmap() + val width = bitmap.width + val height = bitmap.height + + val bitmapRatio = width.toFloat() / height.toFloat() + val maxRatio = maxWidth.toFloat() / maxHeight.toFloat() + + var finalWidth = maxWidth + var finalHeight = maxHeight + + if (maxRatio > bitmapRatio) { + finalWidth = (maxHeight.toFloat() * bitmapRatio).toInt() + } else { + finalHeight = (maxWidth.toFloat() / bitmapRatio).toInt() + } + return Bitmap + .createScaledBitmap(bitmap, finalWidth, finalHeight, true) + .asImageBitmap() + } + + fun onResizeChanged(newSize: IntSize) { + when (true) { + isCropMode.value -> { + cropWindow.updateOnDrawAreaSizeChange(newSize) + return + } + + isResizeMode.value -> { + if ( + backgroundImage.value?.width == + imageSize.width && + backgroundImage.value?.height == + imageSize.height + ) { + val editMatrixScale = scaleToFitOnEdit().scale + resizeOperation + .updateEditMatrixScale(editMatrixScale) + } + if ( + resizeOperation.isApplied() + ) { + resizeOperation.resetApply() + } + return + } + + isRotateMode.value -> { + scaleToFitOnEdit() + return + } + + isZoomMode.value -> { + return + } + + else -> { + scaleToFit() + return + } + } + } + + fun fitImage( + imageBitmap: ImageBitmap, + maxWidth: Int, + maxHeight: Int + ): ImageViewParams { + val bitmap = imageBitmap.asAndroidBitmap() + val width = bitmap.width + val height = bitmap.height + + val bitmapRatio = width.toFloat() / height.toFloat() + val maxRatio = maxWidth.toFloat() / maxHeight.toFloat() + + var finalWidth = maxWidth + var finalHeight = maxHeight + + if (maxRatio > bitmapRatio) { + finalWidth = (maxHeight.toFloat() * bitmapRatio).toInt() + } else { + finalHeight = (maxWidth.toFloat() / bitmapRatio).toInt() + } + return ImageViewParams( + IntSize( + finalWidth, + finalHeight, + ), + ResizeOperation.Scale( + finalWidth.toFloat() / width.toFloat(), + finalHeight.toFloat() / height.toFloat() + ) + ) + } + + fun fitBackground( + resolution: IntSize, + maxWidth: Int, + maxHeight: Int + ): ImageViewParams { + + val width = resolution.width + val height = resolution.height + + val resolutionRatio = width.toFloat() / height.toFloat() + val maxRatio = maxWidth.toFloat() / maxHeight.toFloat() + + var finalWidth = maxWidth + var finalHeight = maxHeight + + if (maxRatio > resolutionRatio) { + finalWidth = (maxHeight.toFloat() * resolutionRatio).toInt() + } else { + finalHeight = (maxWidth.toFloat() / resolutionRatio).toInt() + } + return ImageViewParams( + IntSize( + finalWidth, + finalHeight, + ), + ResizeOperation.Scale( + finalWidth.toFloat() / width.toFloat(), + finalHeight.toFloat() / height.toFloat() + ) + ) + } + + class ImageViewParams( + val drawArea: IntSize, + val scale: ResizeOperation.Scale + ) + + private companion object { + private const val DRAW = "draw" + private const val CROP = "crop" + private const val RESIZE = "resize" + private const val ROTATE = "rotate" + private const val BLUR = "blur" + } +} + +class DrawPath( + val path: Path, + val paint: Paint +) + +fun Paint.copy(): Paint { + val from = this + return Paint().apply { + alpha = from.alpha + isAntiAlias = from.isAntiAlias + color = from.color + blendMode = from.blendMode + style = from.style + strokeWidth = from.strokeWidth + strokeCap = from.strokeCap + strokeJoin = from.strokeJoin + strokeMiterLimit = from.strokeMiterLimit + filterQuality = from.filterQuality + shader = from.shader + colorFilter = from.colorFilter + pathEffect = from.pathEffect + asFrameworkPaint().apply { + maskFilter = from.asFrameworkPaint().maskFilter + } + } +} + +fun defaultPaint(): Paint { + return Paint().apply { + color = Color.White + strokeWidth = 14f + isAntiAlias = true + style = PaintingStyle.Stroke + strokeJoin = StrokeJoin.Round + strokeCap = StrokeCap.Round + } +} diff --git a/canvas/src/main/java/dev/arkbuilders/canvas/presentation/edit/ColorPickerDialog.kt b/canvas/src/main/java/dev/arkbuilders/canvas/presentation/edit/ColorPickerDialog.kt new file mode 100644 index 0000000..8f8650a --- /dev/null +++ b/canvas/src/main/java/dev/arkbuilders/canvas/presentation/edit/ColorPickerDialog.kt @@ -0,0 +1,254 @@ +package dev.arkbuilders.canvas.presentation.edit + +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.core.tween +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.BoxScope +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.wrapContentHeight +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyListState +import androidx.compose.foundation.lazy.LazyRow +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.Icon +import androidx.compose.material.Text +import androidx.compose.material.TextButton +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.KeyboardArrowLeft +import androidx.compose.material.icons.filled.KeyboardArrowRight +import androidx.compose.runtime.Composable +import androidx.compose.runtime.MutableState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.LaunchedEffect +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.graphics.Color +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.res.vectorResource +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.compose.ui.window.Dialog +import com.godaddy.android.colorpicker.ClassicColorPicker +import com.godaddy.android.colorpicker.HsvColor +import dev.arkbuilders.canvas.R +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.launch + +@Composable +fun ColorPickerDialog( + isVisible: MutableState, + initialColor: Color, + usedColors: List = listOf(), + enableEyeDropper: Boolean, + onToggleEyeDropper: () -> Unit, + onColorChanged: (Color) -> Unit, +) { + if (!isVisible.value) return + + var currentColor by remember { + mutableStateOf(HsvColor.from(initialColor)) + } + + val finish = { + onColorChanged(currentColor.toColor()) + isVisible.value = false + } + + Dialog( + onDismissRequest = { + isVisible.value = false + } + ) { + Column( + Modifier + .fillMaxWidth() + .wrapContentHeight() + .background(Color.White, RoundedCornerShape(5)) + .padding(24.dp), + horizontalAlignment = Alignment.CenterHorizontally + ) { + if (usedColors.isNotEmpty()) { + Box( + Modifier + .fillMaxWidth() + ) { + val state = rememberLazyListState() + + LazyRow( + Modifier + .align(Alignment.Center), + state = state + ) { + items(usedColors) { color -> + Box( + Modifier + .padding( + start = 5.dp, + end = 5.dp, + top = 12.dp, + bottom = 12.dp + ) + .size(25.dp) + .clip(CircleShape) + .background(color) + .clickable { + currentColor = HsvColor.from(color) + finish() + } + ) + } + } + LaunchedEffect(state) { + scrollToEnd(state, this) + } + UsedColorsFlowHint( + { enableScroll(state) }, + { checkScroll(state).first }, + { checkScroll(state).second } + ) + } + } + ClassicColorPicker( + modifier = Modifier + .fillMaxWidth() + .height(250.dp), + color = currentColor.toColor(), + onColorChanged = { + currentColor = it + } + ) + if (enableEyeDropper) { + Box(Modifier.padding(8.dp)) { + Box( + Modifier + .size(50.dp) + .clip(CircleShape) + .clickable { + onToggleEyeDropper() + isVisible.value = false + }, + contentAlignment = Alignment.Center + ) { + Icon( + ImageVector.vectorResource(R.drawable.ic_eyedropper), + "", + Modifier.size(25.dp) + ) + } + } + } + TextButton( + modifier = Modifier + .padding(top = 8.dp) + .fillMaxWidth(), + onClick = finish + ) { + Row(verticalAlignment = Alignment.CenterVertically) { + Box( + modifier = Modifier + .padding(12.dp) + .size(50.dp) + .border( + 2.dp, + Color.LightGray, + CircleShape + ) + .padding(6.dp) + .clip(CircleShape) + .background(color = currentColor.toColor()) + ) + Text(text = "Pick", fontSize = 18.sp) + } + } + } + } +} + +fun scrollToEnd(state: LazyListState, scope: CoroutineScope) { + scope.launch { + if (enableScroll(state)) { + val lastIndex = state.layoutInfo.totalItemsCount - 1 + state.scrollToItem(lastIndex, 0) + } + } +} + +fun enableScroll(state: LazyListState): Boolean { + return state.layoutInfo.totalItemsCount != + state.layoutInfo.visibleItemsInfo.size +} + +fun checkScroll(state: LazyListState): Pair { + var scrollIsAtStart = true + var scrollIsAtEnd = false + if (enableScroll(state)) { + val totalItems = state.layoutInfo.totalItemsCount + val visibleItems = state.layoutInfo.visibleItemsInfo.size + val itemSize = + state.layoutInfo.visibleItemsInfo.firstOrNull()?.size + ?: 0 + val rowSize = itemSize * totalItems + val visibleRowSize = itemSize * visibleItems + val scrollValue = state.firstVisibleItemIndex * itemSize + val maxScrollValue = rowSize - visibleRowSize + scrollIsAtStart = scrollValue == 0 + scrollIsAtEnd = scrollValue == maxScrollValue + } + return scrollIsAtStart to scrollIsAtEnd +} + +@Composable +fun BoxScope.UsedColorsFlowHint( + scrollIsEnabled: () -> Boolean, + scrollIsAtStart: () -> Boolean, + scrollIsAtEnd: () -> Boolean +) { + AnimatedVisibility( + visible = scrollIsEnabled() && ( + scrollIsAtEnd() || (!scrollIsAtStart() && !scrollIsAtEnd()) + ), + enter = fadeIn(tween(500)), + exit = fadeOut(tween(500)), + modifier = Modifier + .background(Color.White) + .align(Alignment.CenterStart) + ) { + Icon( + Icons.Filled.KeyboardArrowLeft, + contentDescription = null, + Modifier.size(32.dp) + ) + } + AnimatedVisibility( + visible = scrollIsEnabled() && ( + scrollIsAtStart() || (!scrollIsAtStart() && !scrollIsAtEnd()) + ), + enter = fadeIn(tween(500)), + exit = fadeOut(tween(500)), + modifier = Modifier + .background(Color.White) + .align(Alignment.CenterEnd) + ) { + Icon( + Icons.Filled.KeyboardArrowRight, + contentDescription = null, + Modifier.size(32.dp) + ) + } +} diff --git a/canvas/src/main/java/dev/arkbuilders/canvas/presentation/edit/ConfirmClearDialog.kt b/canvas/src/main/java/dev/arkbuilders/canvas/presentation/edit/ConfirmClearDialog.kt new file mode 100644 index 0000000..57a843b --- /dev/null +++ b/canvas/src/main/java/dev/arkbuilders/canvas/presentation/edit/ConfirmClearDialog.kt @@ -0,0 +1,52 @@ +package dev.arkbuilders.canvas.presentation.edit + +import androidx.compose.foundation.layout.padding +import androidx.compose.material.AlertDialog +import androidx.compose.material.Button +import androidx.compose.material.Text +import androidx.compose.material.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.MutableState +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp + +@Composable +fun ConfirmClearDialog( + show: MutableState, + onConfirm: () -> Unit +) { + if (!show.value) return + + AlertDialog( + onDismissRequest = { + show.value = false + }, + title = { + Text( + modifier = Modifier.padding(top = 4.dp, bottom = 8.dp), + text = "Are you sure to clear all edits?", + fontSize = 16.sp + ) + }, + confirmButton = { + Button( + onClick = { + show.value = false + onConfirm() + } + ) { + Text("Clear") + } + }, + dismissButton = { + TextButton( + onClick = { + show.value = false + } + ) { + Text("Cancel") + } + } + ) +} diff --git a/canvas/src/main/java/dev/arkbuilders/canvas/presentation/edit/EditScreen.kt b/canvas/src/main/java/dev/arkbuilders/canvas/presentation/edit/EditScreen.kt new file mode 100644 index 0000000..db7b513 --- /dev/null +++ b/canvas/src/main/java/dev/arkbuilders/canvas/presentation/edit/EditScreen.kt @@ -0,0 +1,810 @@ +@file:OptIn(ExperimentalComposeUiApi::class) + +package dev.arkbuilders.canvas.presentation.edit + +import android.os.Build +import android.view.MotionEvent +import androidx.annotation.RequiresApi +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.core.tween +import androidx.compose.animation.expandVertically +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.shrinkVertically +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.horizontalScroll +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.BoxScope +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.IntrinsicSize +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.wrapContentHeight +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.AlertDialog +import androidx.compose.material.Button +import androidx.compose.material.ButtonDefaults +import androidx.compose.material.Icon +import androidx.compose.material.Slider +import androidx.compose.material.Text +import androidx.compose.material.TextButton +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.KeyboardArrowDown +import androidx.compose.material.icons.filled.KeyboardArrowLeft +import androidx.compose.material.icons.filled.KeyboardArrowRight +import androidx.compose.material.icons.filled.KeyboardArrowUp +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.ExperimentalComposeUiApi +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.input.pointer.pointerInteropFilter +import androidx.compose.ui.layout.onSizeChanged +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.res.vectorResource +import androidx.compose.ui.unit.IntSize +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.fragment.app.FragmentManager +import dev.arkbuilders.canvas.R +import dev.arkbuilders.canvas.presentation.data.Resolution +import dev.arkbuilders.canvas.presentation.drawing.ArkColorPalette +import dev.arkbuilders.canvas.presentation.drawing.EditCanvas +import dev.arkbuilders.canvas.presentation.edit.blur.BlurIntensityPopup +import dev.arkbuilders.canvas.presentation.edit.crop.CropAspectRatiosMenu +import dev.arkbuilders.canvas.presentation.edit.resize.Hint +import dev.arkbuilders.canvas.presentation.edit.resize.ResizeInput +import dev.arkbuilders.canvas.presentation.edit.resize.delayHidingHint +import dev.arkbuilders.canvas.presentation.picker.toPx +import dev.arkbuilders.canvas.presentation.theme.Gray +import dev.arkbuilders.canvas.presentation.utils.askWritePermissions +import dev.arkbuilders.canvas.presentation.utils.getActivity +import dev.arkbuilders.canvas.presentation.utils.isWritePermGranted +import java.nio.file.Path + +@RequiresApi(Build.VERSION_CODES.UPSIDE_DOWN_CAKE) +@Composable +fun EditScreen( + imagePath: Path?, + imageUri: String?, + fragmentManager: FragmentManager, + navigateBack: () -> Unit, + launchedFromIntent: Boolean, + maxResolution: Resolution, + onSaveSvg: () -> Unit, + viewModel: EditViewModel, +) { + val context = LocalContext.current + val showDefaultsDialog = remember { + mutableStateOf( + imagePath == null && imageUri == null && !viewModel.isLoaded + ) + } + + if (showDefaultsDialog.value) { + viewModel.editManager.resolution.value?.let { + NewImageOptionsDialog( + it, + maxResolution, + viewModel.editManager.backgroundColor.value, + navigateBack, + viewModel.editManager, + persistDefaults = { color, resolution -> + viewModel.persistDefaults(color, resolution) + }, + onConfirm = { + showDefaultsDialog.value = false + } + ) + } + } + ExitDialog( + viewModel = viewModel, + navigateBack = { + navigateBack() + viewModel.isLoaded = false + }, + launchedFromIntent = launchedFromIntent, + ) + + HandleImageSavedEffect(viewModel, launchedFromIntent, navigateBack) + + if (!showDefaultsDialog.value) + DrawContainer( + viewModel + ) + + Menus( + imagePath, + fragmentManager, + viewModel, + launchedFromIntent, + navigateBack + ) + + if (viewModel.isSavingImage) { + SaveProgress() + } + + if (viewModel.showEyeDropperHint) { + Box( + Modifier.fillMaxSize(), + contentAlignment = Alignment.TopCenter + ) { + Hint(stringResource(R.string.pick_color)) { + delayHidingHint(it) { + viewModel.showEyeDropperHint = false + } + viewModel.showEyeDropperHint + } + } + } +} + +@RequiresApi(Build.VERSION_CODES.UPSIDE_DOWN_CAKE) +@Composable +private fun Menus( + imagePath: Path?, + fragmentManager: FragmentManager, + viewModel: EditViewModel, + launchedFromIntent: Boolean, + navigateBack: () -> Unit, +) { + Box( + Modifier + .fillMaxSize() + ) { + Box( + modifier = Modifier + .fillMaxWidth() + .wrapContentHeight() + ) { + TopMenu( + imagePath, + fragmentManager, + viewModel, + launchedFromIntent, + navigateBack + ) + } + Column( + modifier = Modifier + .align(Alignment.BottomCenter) + .height(IntrinsicSize.Min), + horizontalAlignment = Alignment.CenterHorizontally + ) { + if (viewModel.editManager.isRotateMode.value) + Row { + Icon( + modifier = Modifier + .padding(12.dp) + .size(40.dp) + .clip(CircleShape) + .clickable { + viewModel.editManager.apply { + rotate(-90f) + invalidatorTick.value++ + } + }, + imageVector = ImageVector + .vectorResource(R.drawable.ic_rotate_left), + tint = ArkColorPalette.primary, + contentDescription = null + ) + Icon( + modifier = Modifier + .padding(12.dp) + .size(40.dp) + .clip(CircleShape) + .clickable { + viewModel.editManager.apply { + rotate(90f) + invalidatorTick.value++ + } + }, + imageVector = ImageVector + .vectorResource(R.drawable.ic_rotate_right), + tint = ArkColorPalette.primary, + contentDescription = null + ) + } + + EditMenuContainer(viewModel, navigateBack) + } + } +} + +@OptIn(ExperimentalComposeUiApi::class) +@Composable +private fun DrawContainer( + viewModel: EditViewModel +) { + + val context = LocalContext.current + Box( + modifier = Modifier + .padding(bottom = 32.dp) + .fillMaxSize() + .background( + if (viewModel.editManager.isCropMode.value) Color.White + else Color.Gray + ) + .pointerInteropFilter { event -> + if (event.action == MotionEvent.ACTION_DOWN) + viewModel.strokeSliderExpanded = false + false + } + .onSizeChanged { newSize -> + if (newSize == IntSize.Zero || viewModel.showSavePathDialog) return@onSizeChanged + viewModel.editManager.drawAreaSize.value = newSize + if (viewModel.isLoaded) { + viewModel.editManager.onResizeChanged(newSize) + } + viewModel.loadImage(context) + }, + contentAlignment = Alignment.Center + ) { + EditCanvas(viewModel) + } +} + +@RequiresApi(Build.VERSION_CODES.UPSIDE_DOWN_CAKE) +@Composable +private fun BoxScope.TopMenu( + imagePath: Path?, + fragmentManager: FragmentManager, + viewModel: EditViewModel, + launchedFromIntent: Boolean, + navigateBack: () -> Unit +) { + val context = LocalContext.current + + if (viewModel.showSavePathDialog) + SavePathDialog( + initialImagePath = imagePath, + fragmentManager = fragmentManager, + onDismissClick = { viewModel.showSavePathDialog = false }, + onPositiveClick = { savePath -> + viewModel.saveImage(savePath) + } + ) + if (viewModel.showMoreOptionsPopup) + MoreOptionsPopup( + onDismissClick = { + viewModel.showMoreOptionsPopup = false + }, + onShareClick = { + viewModel.shareImage(context) + viewModel.showMoreOptionsPopup = false + }, + onSaveClick = { + if (!context.isWritePermGranted()) { + context.askWritePermissions() + return@MoreOptionsPopup + } + viewModel.showSavePathDialog = true + }, + onClearEdits = { + viewModel.showConfirmClearDialog.value = true + viewModel.showMoreOptionsPopup = false + } + ) + + ConfirmClearDialog( + viewModel.showConfirmClearDialog, + onConfirm = { + viewModel.editManager.clearEdits() + } + ) + + if ( + !viewModel.menusVisible && + viewModel.editManager.isControlsDisabled() + ) + return + Icon( + modifier = Modifier + .align(Alignment.TopStart) + .padding(8.dp) + .size(36.dp) + .clip(CircleShape) + .clickable { + viewModel.editManager.apply { + if (shouldCancelOperation()) { + viewModel.cancelOperation() + return@clickable + } + if (isZoomMode.value) { + toggleZoomMode() + return@clickable + } + if (isPanMode.value) { + togglePanMode() + return@clickable + } + if ( + !canUndo.value + ) { + if (launchedFromIntent) { + context + .getActivity() + ?.finish() + } else { + navigateBack() + } + } else { + viewModel.showExitDialog = true + } + } + }, + imageVector = ImageVector.vectorResource(R.drawable.ic_arrow_back), + tint = ArkColorPalette.primary, + contentDescription = null + ) + + Row( + Modifier + .align(Alignment.TopEnd) + ) { + Icon( + modifier = Modifier + .padding(8.dp) + .size(36.dp) + .clip(CircleShape) + .clickable { + if (viewModel.editManager.shouldApplyOperation()) { + viewModel.applyOperation() + return@clickable + } + viewModel.showMoreOptionsPopup = true + }, + imageVector = if (viewModel.editManager.shouldApplyOperation()) + ImageVector.vectorResource(R.drawable.ic_check) + else ImageVector.vectorResource(R.drawable.ic_more_vert), + tint = ArkColorPalette.primary, + contentDescription = null + ) + } +} + +@Composable +private fun StrokeWidthPopup( + modifier: Modifier, + viewModel: EditViewModel +) { + val editManager = viewModel.editManager + editManager.setPaintStrokeWidth(viewModel.strokeWidth.dp.toPx()) + if (viewModel.strokeSliderExpanded) { + Column( + modifier = modifier + .fillMaxWidth() + .height(150.dp) + .padding(8.dp) + ) { + Box( + modifier = Modifier + .weight(1f) + ) { + Box( + modifier = Modifier + .padding( + horizontal = 10.dp, + vertical = 5.dp + ) + .align(Alignment.Center) + .fillMaxWidth() + .height(viewModel.strokeWidth.dp) + .clip(RoundedCornerShape(30)) + .background(editManager.paintColor.value) + ) + } + + Slider( + modifier = Modifier + .fillMaxWidth(), + value = viewModel.strokeWidth, + onValueChange = { + viewModel.strokeWidth = it + }, + valueRange = 0.5f..50f, + ) + } + } +} + +@Composable +private fun EditMenuContainer(viewModel: EditViewModel, navigateBack: () -> Unit) { + Column( + Modifier + .fillMaxSize(), + verticalArrangement = Arrangement.Bottom + ) { + CropAspectRatiosMenu( + isVisible = viewModel.editManager.isCropMode.value, + viewModel.editManager.cropWindow + ) + ResizeInput( + isVisible = viewModel.editManager.isResizeMode.value, + viewModel.editManager + ) + + Box( + Modifier + .fillMaxWidth() + .wrapContentHeight() + .clip(RoundedCornerShape(topStartPercent = 30, topEndPercent = 30)) + .background(Gray) + .clickable { + viewModel.menusVisible = !viewModel.menusVisible + }, + contentAlignment = Alignment.Center + ) { + Icon( + if (viewModel.menusVisible) Icons.Filled.KeyboardArrowDown + else Icons.Filled.KeyboardArrowUp, + contentDescription = "", + modifier = Modifier.size(32.dp), + ) + } + AnimatedVisibility( + visible = viewModel.menusVisible, + enter = expandVertically(expandFrom = Alignment.Bottom), + exit = shrinkVertically(shrinkTowards = Alignment.Top) + ) { + EditMenuContent(viewModel, navigateBack) + EditMenuFlowHint( + viewModel.bottomButtonsScrollIsAtStart.value, + viewModel.bottomButtonsScrollIsAtEnd.value + ) + } + } +} + +@Composable +private fun EditMenuContent( + viewModel: EditViewModel, + navigateBack: () -> Unit +) { + val colorDialogExpanded = remember { mutableStateOf(false) } + val scrollState = rememberScrollState() + val editManager = viewModel.editManager + Column( + Modifier + .fillMaxWidth() + .background(Gray) + ) { + StrokeWidthPopup(Modifier, viewModel) + + BlurIntensityPopup(editManager) + + Row( + Modifier + .wrapContentHeight() + .fillMaxWidth() + .padding(start = 10.dp, end = 10.dp) + .horizontalScroll(scrollState) + ) { + Icon( + modifier = Modifier + .padding(12.dp) + .size(40.dp) + .clip(CircleShape) + .clickable { + if ( + editManager.isEligibleForUndoOrRedo() + ) { + editManager.undo() + } + }, + imageVector = ImageVector.vectorResource(R.drawable.ic_undo), + tint = if ( + editManager.canUndo.value && (editManager.isEligibleForUndoOrRedo()) + ) ArkColorPalette.primary else Color.Black, + contentDescription = null + ) + Icon( + modifier = Modifier + .padding(12.dp) + .size(40.dp) + .clip(CircleShape) + .clickable { + if (editManager.isEligibleForUndoOrRedo()) editManager.redo() + }, + imageVector = ImageVector.vectorResource(R.drawable.ic_redo), + tint = if ( + editManager.canRedo.value && + (editManager.isEligibleForUndoOrRedo()) + ) ArkColorPalette.primary else Color.Black, + contentDescription = null + ) + Box( + modifier = Modifier + .padding(12.dp) + .size(40.dp) + .clip(CircleShape) + .background(color = editManager.paintColor.value) + .clickable { + if (editManager.isEyeDropperMode.value) { + viewModel.toggleEyeDropper() + viewModel.cancelEyeDropper() + colorDialogExpanded.value = true + return@clickable + } + if (editManager.shouldExpandColorDialog()) + colorDialogExpanded.value = true + } + ) + ColorPickerDialog( + isVisible = colorDialogExpanded, + initialColor = editManager.paintColor.value, + usedColors = viewModel.usedColors, + enableEyeDropper = true, + onToggleEyeDropper = { + viewModel.toggleEyeDropper() + }, + onColorChanged = { + editManager.setPaintColor(it) + viewModel.trackColor(it) + } + ) + Icon( + modifier = Modifier + .padding(12.dp) + .size(40.dp) + .clip(CircleShape) + .clickable { + if (editManager.isEligibleForStrokeExpandOrErase()) + viewModel.strokeSliderExpanded = !viewModel.strokeSliderExpanded + }, + imageVector = ImageVector.vectorResource(R.drawable.ic_line_weight), + tint = if (editManager.isEligibleForUndoOrRedo()) editManager.paintColor.value else Color.Black, + contentDescription = null + ) + Icon( + modifier = Modifier + .padding(12.dp) + .size(40.dp) + .clip(CircleShape) + .clickable { + if (editManager.isEligibleForStrokeExpandOrErase()) + editManager.toggleEraseMode() + }, + imageVector = ImageVector.vectorResource(R.drawable.ic_eraser), + tint = if ( + editManager.isEraseMode.value + ) + ArkColorPalette.primary + else + Color.Black, + contentDescription = null + ) + Icon( + modifier = Modifier + .padding(12.dp) + .size(40.dp) + .clip(CircleShape) + .clickable { + if (editManager.isEligibleForPanOrZoomMode()) editManager.toggleZoomMode() + }, + imageVector = ImageVector.vectorResource(R.drawable.ic_zoom_in), + tint = if ( + editManager.isZoomMode.value + ) + ArkColorPalette.primary + else + Color.Black, + contentDescription = null + ) + Icon( + modifier = Modifier + .padding(12.dp) + .size(40.dp) + .clip(CircleShape) + .clickable { + if (editManager.isEligibleForPanOrZoomMode()) editManager.togglePanMode() + }, + imageVector = ImageVector.vectorResource(R.drawable.ic_pan_tool), + tint = if ( + editManager.isPanMode.value + ) + ArkColorPalette.primary + else + Color.Black, + contentDescription = null + ) + Icon( + modifier = Modifier + .padding(12.dp) + .size(40.dp) + .clip(CircleShape) + .clickable { + if (!editManager.isEligibleForCropOrRotate()) return@clickable + editManager.enterCropMode() + viewModel.menusVisible = !editManager.isCropMode.value + }, + imageVector = ImageVector.vectorResource(R.drawable.ic_crop), + tint = if ( + editManager.isCropMode.value + ) ArkColorPalette.primary + else + Color.Black, + contentDescription = null + ) + Icon( + modifier = Modifier + .padding(12.dp) + .size(40.dp) + .clip(CircleShape) + .clickable { + if (!editManager.isEligibleForCropOrRotate()) return@clickable + editManager.enterRotateMode() + viewModel.menusVisible = !editManager.isRotateMode.value + }, + imageVector = ImageVector + .vectorResource(R.drawable.ic_rotate_90_degrees_ccw), + tint = if (editManager.isRotateMode.value) + ArkColorPalette.primary + else + Color.Black, + contentDescription = null + ) + Icon( + modifier = Modifier + .padding(12.dp) + .size(40.dp) + .clip(CircleShape) + .clickable { + editManager.enterResizeMode() + viewModel.menusVisible = !editManager.isResizeMode.value + }, + imageVector = ImageVector + .vectorResource(R.drawable.ic_aspect_ratio), + tint = if (editManager.isResizeMode.value) + ArkColorPalette.primary + else + Color.Black, + contentDescription = null + ) + Icon( + modifier = Modifier + .padding(12.dp) + .size(40.dp) + .clip(CircleShape) + .clickable { + editManager.enterBlurMode(viewModel.strokeSliderExpanded) + }, + imageVector = ImageVector + .vectorResource(R.drawable.ic_blur_on), + tint = if (editManager.isBlurMode.value) + ArkColorPalette.primary + else + Color.Black, + contentDescription = null + ) + } + } + viewModel.onBottomButtonStateChange(scrollState.value, 0, scrollState.maxValue) +} + +@Composable +fun EditMenuFlowHint( + scrollIsAtStart: Boolean = true, + scrollIsAtEnd: Boolean = false +) { + Box(Modifier.fillMaxSize()) { + AnimatedVisibility( + visible = scrollIsAtEnd || (!scrollIsAtStart && !scrollIsAtEnd), + enter = fadeIn(tween(durationMillis = 1000)), + exit = fadeOut((tween(durationMillis = 1000))), + modifier = Modifier.align(Alignment.BottomStart) + ) { + Icon( + Icons.Filled.KeyboardArrowLeft, + contentDescription = null, + Modifier + .background(Gray) + .padding(top = 16.dp, bottom = 16.dp) + .size(32.dp) + ) + } + AnimatedVisibility( + visible = scrollIsAtStart || (!scrollIsAtStart && !scrollIsAtEnd), + enter = fadeIn(tween(durationMillis = 1000)), + exit = fadeOut((tween(durationMillis = 1000))), + modifier = Modifier.align(Alignment.BottomEnd) + ) { + Icon( + Icons.Filled.KeyboardArrowRight, + contentDescription = null, + Modifier + .background(Gray) + .padding(top = 16.dp, bottom = 16.dp) + .size(32.dp) + ) + } + } +} + +@Composable +private fun HandleImageSavedEffect( + viewModel: EditViewModel, + launchedFromIntent: Boolean, + navigateBack: () -> Unit, +) { + val context = LocalContext.current + LaunchedEffect(viewModel.imageSaved) { + if (!viewModel.imageSaved) + return@LaunchedEffect + if (launchedFromIntent) + context.getActivity()?.finish() + else + navigateBack() + } +} + +@Composable +private fun ExitDialog( + viewModel: EditViewModel, + navigateBack: () -> Unit, + launchedFromIntent: Boolean +) { + if (!viewModel.showExitDialog) return + + val context = LocalContext.current + + AlertDialog( + onDismissRequest = { + viewModel.showExitDialog = false + }, + title = { + Text( + modifier = Modifier.padding(top = 4.dp, bottom = 8.dp), + text = "Do you want to save the changes?", + fontSize = 16.sp + ) + }, + confirmButton = { + Button( + onClick = { + viewModel.showExitDialog = false + viewModel.showSavePathDialog = true + }, + colors = ButtonDefaults.buttonColors(backgroundColor = ArkColorPalette.primary) + ) { + Text("Save") + } + }, + dismissButton = { + TextButton( + onClick = { + viewModel.showExitDialog = false + if (launchedFromIntent) { + context.getActivity()?.finish() + } else { + navigateBack() + } + }, + colors = ButtonDefaults.textButtonColors(contentColor = ArkColorPalette.primary) + ) { + Text( + text = "Exit", + color = ArkColorPalette.primary, + ) + } + } + ) +} diff --git a/canvas/src/main/java/dev/arkbuilders/canvas/presentation/edit/EditViewModel.kt b/canvas/src/main/java/dev/arkbuilders/canvas/presentation/edit/EditViewModel.kt new file mode 100644 index 0000000..f3c262a --- /dev/null +++ b/canvas/src/main/java/dev/arkbuilders/canvas/presentation/edit/EditViewModel.kt @@ -0,0 +1,337 @@ +package dev.arkbuilders.canvas.presentation.edit + +import android.content.Context +import android.content.Intent +import android.graphics.Bitmap +import android.graphics.Matrix +import android.net.Uri +import android.view.MotionEvent +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.geometry.Rect +import androidx.compose.ui.graphics.Canvas +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.ImageBitmap +import androidx.compose.ui.graphics.ImageBitmapConfig +import androidx.compose.ui.graphics.Paint +import androidx.compose.ui.graphics.asAndroidBitmap +import androidx.compose.ui.graphics.nativeCanvas +import androidx.compose.ui.unit.toSize +import androidx.core.content.FileProvider +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import dev.arkbuilders.canvas.presentation.data.Preferences +import dev.arkbuilders.canvas.presentation.data.Resolution +import dev.arkbuilders.canvas.presentation.drawing.EditManager +import dev.arkbuilders.canvas.presentation.resourceloader.CanvasResourceManager +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch +import timber.log.Timber +import java.io.File +import java.nio.file.Path +import kotlin.io.path.name +import kotlin.system.measureTimeMillis + +class EditViewModel( + private val primaryColor: Long, + private val launchedFromIntent: Boolean, + private val imagePath: Path?, + private val imageUri: String?, + private val maxResolution: Resolution, + private val prefs: Preferences, + val editManager: EditManager, + private val bitMapResourceManager: CanvasResourceManager, + private val svgResourceManager: CanvasResourceManager, +) : ViewModel() { + var strokeSliderExpanded by mutableStateOf(false) + var menusVisible by mutableStateOf(true) + var strokeWidth by mutableStateOf(5f) + var showSavePathDialog by mutableStateOf(false) + val showOverwriteCheckbox = mutableStateOf(imagePath != null) + var showExitDialog by mutableStateOf(false) + var showMoreOptionsPopup by mutableStateOf(false) + var imageSaved by mutableStateOf(false) + var isSavingImage by mutableStateOf(false) + var showEyeDropperHint by mutableStateOf(false) + val showConfirmClearDialog = mutableStateOf(false) + var isLoaded by mutableStateOf(false) + var exitConfirmed = false + private set + val bottomButtonsScrollIsAtStart = mutableStateOf(true) + val bottomButtonsScrollIsAtEnd = mutableStateOf(false) + + private val _usedColors = mutableListOf() + val usedColors: List = _usedColors + + fun onBottomButtonStateChange(scrollStateValue: Int, minStateValue: Int = 0, maxStateValue: Int) { + bottomButtonsScrollIsAtStart.value = scrollStateValue == minStateValue + bottomButtonsScrollIsAtEnd.value = scrollStateValue == maxStateValue + } + + private fun loadResource(path: Path) { + viewModelScope.launch { + if (path.name.endsWith(".png")) { + bitMapResourceManager.loadResource(path) + } else { + svgResourceManager.loadResource(path) + } + } + } + + private fun loadUsedColors() { + viewModelScope.launch { + _usedColors.addAll(prefs.readUsedColors()) + + val color = if (_usedColors.isNotEmpty()) { + _usedColors.last() + } else { + val defaultColor = Color.Blue + + _usedColors.add(defaultColor) + defaultColor + } + + editManager.setPaintColor(color) + } + } + + private fun initDefaults() { + viewModelScope.launch { + editManager.initDefaults( + prefs.readDefaults(), + maxResolution + ) + } + } + + init { + imagePath?.let { + loadResource(it) + } + initDefaults() + loadUsedColors() + } + + fun loadImage(context: Context) { + isLoaded = true + editManager.scaleToFit() + } + + fun saveImage(path: Path) { + viewModelScope.launch(Dispatchers.IO) { + isSavingImage = true + if (path.name.endsWith("svg")) { + svgResourceManager.saveResource(path) + } else { + bitMapResourceManager.saveResource(path) + } + imageSaved = true + isSavingImage = false + showSavePathDialog = false + } + } + + fun shareImage(context: Context) = + viewModelScope.launch(Dispatchers.IO) { + val intent = Intent(Intent.ACTION_SEND) + val uri = getCachedImageUri(context) + intent.type = "image/*" + intent.putExtra(Intent.EXTRA_STREAM, uri) + context.apply { + startActivity( + Intent.createChooser( + intent, + "Share" + ) + ) + } + } + + fun getImageUri( + context: Context, + bitmap: Bitmap? = null, + name: String = "" + ) = getCachedImageUri(context, bitmap, name) + + private fun getCachedImageUri( + context: Context, + bitmap: Bitmap? = null, + name: String = "" + ): Uri { + var uri: Uri? = null + val imageCacheFolder = File(context.cacheDir, "images") + val imgBitmap = bitmap ?: getEditedImage().asAndroidBitmap() + try { + imageCacheFolder.mkdirs() + val file = File(imageCacheFolder, "image$name.png") + file.outputStream().use { out -> + imgBitmap + .compress(Bitmap.CompressFormat.PNG, 100, out) + } + Timber.tag("Cached image path").d(file.path.toString()) + uri = FileProvider.getUriForFile( + context, + "${context.packageName}.fileprovider", + file + ) + } catch (e: Exception) { + e.printStackTrace() + } + return uri!! + } + + fun trackColor(color: Color) { + _usedColors.remove(color) + _usedColors.add(color) + + val excess = _usedColors.size - KEEP_USED_COLORS + repeat(excess) { + _usedColors.removeFirst() + } + + viewModelScope.launch { + prefs.persistUsedColors(usedColors) + } + } + + fun toggleEyeDropper() { + editManager.toggleEyeDropper() + } + + fun cancelEyeDropper() { + editManager.setPaintColor(usedColors.last()) + } + + fun applyEyeDropper(action: Int, x: Int, y: Int) { + try { + val bitmap = getEditedImage().asAndroidBitmap() + val imageX = (x * editManager.bitmapScale.x).toInt() + val imageY = (y * editManager.bitmapScale.y).toInt() + val pixel = bitmap.getPixel(imageX, imageY) + val color = Color(pixel) + if (color == Color.Transparent) { + showEyeDropperHint = true + return + } + when (action) { + MotionEvent.ACTION_DOWN, MotionEvent.ACTION_UP -> { + trackColor(color) + toggleEyeDropper() + menusVisible = true + } + } + editManager.setPaintColor(color) + } catch (e: Exception) { + e.printStackTrace() + } + } + + fun getCombinedImageBitmap(): ImageBitmap { + val size = editManager.imageSize + val drawBitmap = ImageBitmap( + size.width, + size.height, + ImageBitmapConfig.Argb8888 + ) + val combinedBitmap = + ImageBitmap(size.width, size.height, ImageBitmapConfig.Argb8888) + + val time = measureTimeMillis { + val backgroundPaint = Paint().also { + it.color = editManager.backgroundColor.value + } + val drawCanvas = Canvas(drawBitmap) + val combinedCanvas = Canvas(combinedBitmap) + val matrix = Matrix().apply { + if (editManager.rotationAngles.isNotEmpty()) { + val centerX = size.width / 2 + val centerY = size.height / 2 + setRotate( + editManager.rotationAngle.value, + centerX.toFloat(), + centerY.toFloat() + ) + } + } + combinedCanvas.drawRect( + Rect(Offset.Zero, size.toSize()), + backgroundPaint + ) + combinedCanvas.nativeCanvas.setMatrix(matrix) + editManager.backgroundImage.value?.let { + combinedCanvas.drawImage( + it, + Offset.Zero, + Paint() + ) + } + editManager.drawPaths.forEach { + drawCanvas.drawPath(it.path, it.paint) + } + combinedCanvas.drawImage(drawBitmap, Offset.Zero, Paint()) + } + Timber.tag("edit-viewmodel: getCombinedImageBitmap").d( + "processing edits took ${time / 1000} s ${time % 1000} ms" + ) + return combinedBitmap + } + + fun getEditedImage(): ImageBitmap = editManager.getEditedImage() + + fun confirmExit() = viewModelScope.launch { + exitConfirmed = true + isLoaded = false + delay(2_000) + exitConfirmed = false + isLoaded = true + } + + fun applyOperation() { + editManager.applyOperation() + menusVisible = true + } + + fun cancelOperation() { + editManager.apply { + if (isRotateMode.value) { + toggleRotateMode() + cancelRotateMode() + menusVisible = true + } + if (isCropMode.value) { + toggleCropMode() + cancelCropMode() + menusVisible = true + } + if (isResizeMode.value) { + toggleResizeMode() + cancelResizeMode() + menusVisible = true + } + if (isEyeDropperMode.value) { + toggleEyeDropper() + cancelEyeDropper() + menusVisible = true + } + if (isBlurMode.value) { + toggleBlurMode() + blurOperation.cancel() + menusVisible = true + } + scaleToFit() + } + } + + fun persistDefaults(color: Color, resolution: Resolution) { + viewModelScope.launch { + prefs.persistDefaults(color, resolution) + } + } + + companion object { + private const val KEEP_USED_COLORS = 20 + } +} \ No newline at end of file diff --git a/canvas/src/main/java/dev/arkbuilders/canvas/presentation/edit/MoreOptionsPopup.kt b/canvas/src/main/java/dev/arkbuilders/canvas/presentation/edit/MoreOptionsPopup.kt new file mode 100644 index 0000000..cc74834 --- /dev/null +++ b/canvas/src/main/java/dev/arkbuilders/canvas/presentation/edit/MoreOptionsPopup.kt @@ -0,0 +1,131 @@ +package dev.arkbuilders.canvas.presentation.edit + +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.Icon +import androidx.compose.material.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.res.vectorResource +import androidx.compose.ui.unit.IntOffset +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.compose.ui.window.Popup +import androidx.compose.ui.window.PopupProperties +import dev.arkbuilders.canvas.R +import dev.arkbuilders.canvas.presentation.picker.toPx + +@Composable +fun MoreOptionsPopup( + onClearEdits: () -> Unit, + onShareClick: () -> Unit, + onSaveClick: () -> Unit, + onDismissClick: () -> Unit +) { + Popup( + alignment = Alignment.TopEnd, + offset = IntOffset( + -8.dp.toPx().toInt(), + 8.dp.toPx().toInt() + ), + properties = PopupProperties( + focusable = true + ), + onDismissRequest = onDismissClick, + ) { + Column( + Modifier + .background( + Color.LightGray, + RoundedCornerShape(8) + ) + .padding(8.dp) + ) { + Row { + Column( + Modifier + .padding(8.dp) + .clip(RoundedCornerShape(5)) + .clickable { + onClearEdits() + } + ) { + + Icon( + modifier = Modifier + .padding(8.dp) + .size(36.dp), + imageVector = + ImageVector.vectorResource(R.drawable.ic_clear), + contentDescription = null + ) + Text( + text = stringResource(R.string.clear), + Modifier + .padding(bottom = 8.dp) + .align(Alignment.CenterHorizontally), + fontSize = 12.sp + ) + } + Column( + Modifier + .padding(8.dp) + .clip(RoundedCornerShape(5)) + .clickable { + onShareClick() + } + ) { + + Icon( + modifier = Modifier + .padding(8.dp) + .size(36.dp), + imageVector = ImageVector + .vectorResource(R.drawable.ic_share), + contentDescription = null + ) + Text( + text = stringResource(R.string.share), + Modifier + .padding(bottom = 8.dp) + .align(Alignment.CenterHorizontally), + fontSize = 12.sp + ) + } + Column( + Modifier + .padding(8.dp) + .clip(RoundedCornerShape(5)) + .clickable { + onSaveClick() + } + ) { + Icon( + modifier = Modifier + .padding(8.dp) + .size(36.dp), + imageVector = ImageVector.vectorResource(R.drawable.ic_save), + contentDescription = null + ) + Text( + text = stringResource(R.string.save), + Modifier + .padding(bottom = 8.dp) + .align(Alignment.CenterHorizontally), + fontSize = 12.sp + ) + } + } + } + } +} diff --git a/canvas/src/main/java/dev/arkbuilders/canvas/presentation/edit/NewImageOptionsDialog.kt b/canvas/src/main/java/dev/arkbuilders/canvas/presentation/edit/NewImageOptionsDialog.kt new file mode 100644 index 0000000..92dcc1c --- /dev/null +++ b/canvas/src/main/java/dev/arkbuilders/canvas/presentation/edit/NewImageOptionsDialog.kt @@ -0,0 +1,329 @@ +package dev.arkbuilders.canvas.presentation.edit + +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.wrapContentHeight +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.material.ButtonDefaults +import androidx.compose.material.Checkbox +import androidx.compose.material.Text +import androidx.compose.material.TextButton +import androidx.compose.material.TextField +import androidx.compose.runtime.Composable +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.graphics.Color +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import androidx.compose.ui.window.Dialog +import androidx.core.text.isDigitsOnly +import dev.arkbuilders.canvas.R +import dev.arkbuilders.canvas.presentation.data.Resolution +import dev.arkbuilders.canvas.presentation.drawing.ArkColorPalette +import dev.arkbuilders.canvas.presentation.drawing.EditManager +import dev.arkbuilders.canvas.presentation.edit.resize.Hint +import dev.arkbuilders.canvas.presentation.edit.resize.delayHidingHint +import dev.arkbuilders.canvas.presentation.theme.Gray + +@Composable +fun NewImageOptionsDialog( + defaultResolution: Resolution, + maxResolution: Resolution, + _backgroundColor: Color, + navigateBack: () -> Unit, + editManager: EditManager, + persistDefaults: (Color, Resolution) -> Unit, + onConfirm: () -> Unit +) { + var isVisible by remember { mutableStateOf(true) } + var backgroundColor by remember { + mutableStateOf(_backgroundColor) + } + val showColorDialog = remember { mutableStateOf(false) } + + ColorPickerDialog( + isVisible = showColorDialog, + initialColor = backgroundColor, + enableEyeDropper = false, + onToggleEyeDropper = {}, + onColorChanged = { + backgroundColor = it + } + ) + + if (isVisible) { + var width by remember { + mutableStateOf(defaultResolution.width.toString()) + } + var height by remember { + mutableStateOf(defaultResolution.height.toString()) + } + var widthError by remember { + mutableStateOf(0.toString()) + } + var heightError by remember { + mutableStateOf(0.toString()) + } + var rememberDefaults by remember { mutableStateOf(false) } + var showHint by remember { mutableStateOf(false) } + var hint by remember { mutableStateOf("") } + val maxHeightHint = stringResource( + R.string.height_too_large, + maxResolution.height + ) + val minHeightHint = stringResource( + R.string.height_not_accepted, + heightError + ) + val maxWidthHint = stringResource( + R.string.width_too_large, + maxResolution.width + ) + val minWidthHint = stringResource( + R.string.width_not_accepted, + widthError + ) + val digitsOnlyHint = stringResource( + R.string.digits_only + ) + val widthEmptyHint = stringResource( + R.string.width_empty + ) + val heightEmptyHint = stringResource( + R.string.height_empty + ) + + Dialog( + onDismissRequest = { + isVisible = false + navigateBack() + } + ) { + Column( + Modifier + .background(Color.White, RoundedCornerShape(5)) + .wrapContentHeight(), + horizontalAlignment = Alignment.CenterHorizontally + ) { + Text( + "Customize new image", + Modifier.padding(top = 10.dp) + ) + Row( + Modifier.padding( + start = 8.dp, + end = 8.dp, + top = 20.dp, + bottom = 12.dp + ) + ) { + TextField( + modifier = Modifier + .padding(end = 6.dp) + .fillMaxWidth(0.5f), + value = width, + onValueChange = { + if (!it.isDigitsOnly()) { + hint = digitsOnlyHint + showHint = true + return@TextField + } + if ( + it.isNotEmpty() && it.isDigitsOnly() && + it.toInt() > maxResolution.width + ) { + hint = maxWidthHint + showHint = true + return@TextField + } + if ( + it.isNotEmpty() && it.isDigitsOnly() && + it.toInt() <= 0 + ) { + widthError = it + hint = minWidthHint + showHint = true + return@TextField + } + if (it.isDigitsOnly()) { + width = it + } + }, + label = { + Text( + stringResource(R.string.width), + Modifier.fillMaxWidth(), + color = ArkColorPalette.primary, + textAlign = TextAlign.Center + ) + }, + textStyle = TextStyle( + color = Color.DarkGray, + textAlign = TextAlign.Center + ), + singleLine = true, + keyboardOptions = KeyboardOptions( + keyboardType = KeyboardType.Number + ) + ) + TextField( + modifier = Modifier + .padding(start = 6.dp) + .fillMaxWidth(), + value = height, + onValueChange = { + if (!it.isDigitsOnly()) { + hint = digitsOnlyHint + showHint = true + return@TextField + } + if ( + it.isNotEmpty() && it.isDigitsOnly() && + it.toInt() > maxResolution.height + ) { + hint = maxHeightHint + showHint = true + return@TextField + } + if ( + it.isNotEmpty() && it.isDigitsOnly() && + it.toInt() <= 0 + ) { + heightError = it + hint = minHeightHint + showHint = true + return@TextField + } + if (it.isDigitsOnly()) { + height = it + } + }, + label = { + Text( + text = stringResource(R.string.height), + modifier = Modifier.fillMaxWidth(), + color = ArkColorPalette.primary, + textAlign = TextAlign.Center + ) + }, + textStyle = TextStyle( + color = Color.DarkGray, + textAlign = TextAlign.Center + ), + singleLine = true, + keyboardOptions = KeyboardOptions( + keyboardType = KeyboardType.Number + ) + ) + } + Row( + Modifier + .background(Gray, RoundedCornerShape(5)) + .wrapContentHeight() + .clickable { + showColorDialog.value = true + }, + verticalAlignment = Alignment.CenterVertically + ) { + Text( + stringResource(R.string.background), + Modifier.padding(8.dp) + ) + Box( + Modifier + .size(28.dp) + .padding(2.dp) + .clip(CircleShape) + .border(2.dp, Gray, CircleShape) + .background(backgroundColor) + ) + } + Row( + Modifier + .padding(start = 8.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Checkbox( + checked = rememberDefaults, + onCheckedChange = { + rememberDefaults = it + } + ) + Text("Remember") + } + Row( + Modifier.align( + Alignment.End + ) + ) { + TextButton( + modifier = Modifier + .padding(end = 8.dp), + onClick = { + isVisible = false + navigateBack() + }, + colors = ButtonDefaults.textButtonColors(contentColor = ArkColorPalette.primary) + ) { + Text("Close") + } + TextButton( + colors = ButtonDefaults.buttonColors(backgroundColor = ArkColorPalette.primary), + modifier = Modifier + .padding(end = 8.dp), + onClick = { + if (width.isEmpty()) { + hint = widthEmptyHint + showHint = true + return@TextButton + } + if (height.isEmpty()) { + hint = heightEmptyHint + showHint = true + return@TextButton + } + val resolution = Resolution( + width.toInt(), + height.toInt() + ) + editManager.setImageResolution(resolution) + editManager.setBackgroundColor(backgroundColor) + if (rememberDefaults) + persistDefaults(backgroundColor, resolution) + onConfirm() + isVisible = false + } + ) { + Text(stringResource(R.string.ok)) + } + } + } + + Hint( + hint + ) { + delayHidingHint(it) { + showHint = false + } + showHint + } + } + } +} diff --git a/canvas/src/main/java/dev/arkbuilders/canvas/presentation/edit/Operation.kt b/canvas/src/main/java/dev/arkbuilders/canvas/presentation/edit/Operation.kt new file mode 100644 index 0000000..c22fe88 --- /dev/null +++ b/canvas/src/main/java/dev/arkbuilders/canvas/presentation/edit/Operation.kt @@ -0,0 +1,9 @@ +package dev.arkbuilders.canvas.presentation.edit + +interface Operation { + fun apply() + + fun undo() + + fun redo() +} diff --git a/canvas/src/main/java/dev/arkbuilders/canvas/presentation/edit/SavePathDialog.kt b/canvas/src/main/java/dev/arkbuilders/canvas/presentation/edit/SavePathDialog.kt new file mode 100644 index 0000000..3181be5 --- /dev/null +++ b/canvas/src/main/java/dev/arkbuilders/canvas/presentation/edit/SavePathDialog.kt @@ -0,0 +1,243 @@ +package dev.arkbuilders.canvas.presentation.edit + +import android.os.Build +import androidx.annotation.RequiresApi +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.wrapContentHeight +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.Button +import androidx.compose.material.ButtonDefaults +import androidx.compose.material.Checkbox +import androidx.compose.material.CircularProgressIndicator +import androidx.compose.material.OutlinedTextField +import androidx.compose.material.Text +import androidx.compose.material.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.key +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.graphics.Color +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalLifecycleOwner +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.compose.ui.window.Dialog +import androidx.fragment.app.FragmentManager +import dev.arkbuilders.canvas.R +import dev.arkbuilders.canvas.presentation.drawing.ArkColorPalette +import dev.arkbuilders.canvas.presentation.utils.findNotExistCopyName +import dev.arkbuilders.canvas.presentation.utils.toast +import dev.arkbuilders.components.filepicker.ArkFilePickerConfig +import dev.arkbuilders.components.filepicker.ArkFilePickerFragment +import dev.arkbuilders.components.filepicker.ArkFilePickerMode +import dev.arkbuilders.components.filepicker.onArkPathPicked +import java.nio.file.Files +import java.nio.file.Path +import kotlin.io.path.name + +@RequiresApi(Build.VERSION_CODES.UPSIDE_DOWN_CAKE) +@Composable +fun SavePathDialog( + initialImagePath: Path?, + fragmentManager: FragmentManager, + onDismissClick: () -> Unit, + onPositiveClick: (Path) -> Unit +) { + var currentPath by remember { mutableStateOf(initialImagePath?.parent) } + var imagePath by remember { mutableStateOf(initialImagePath) } + val showOverwriteCheckbox = remember { mutableStateOf(initialImagePath != null) } + var overwriteOriginalPath by remember { mutableStateOf(false) } + var name by remember { + mutableStateOf( + initialImagePath?.let { + it.parent.findNotExistCopyName(it.fileName).name + } ?: "image.png" + ) + } + + val lifecycleOwner = LocalLifecycleOwner.current + + val context = LocalContext.current + + LaunchedEffect(overwriteOriginalPath) { + if (overwriteOriginalPath) { + imagePath?.let { + currentPath = it.parent + name = it.name + } + return@LaunchedEffect + } + imagePath?.let { + name = it.parent.findNotExistCopyName(it.fileName).name + } + } + + key(showOverwriteCheckbox.value) { + Dialog(onDismissRequest = onDismissClick) { + Column( + Modifier + .fillMaxWidth() + .wrapContentHeight() + .background(Color.White, RoundedCornerShape(5)) + .padding(5.dp) + ) { + Text( + modifier = Modifier.padding(5.dp), + text = stringResource(R.string.location), + fontSize = 18.sp + ) + TextButton( + colors = ButtonDefaults.textButtonColors( + contentColor = ArkColorPalette.primary + ) + , + onClick = { + ArkFilePickerFragment + .newInstance( + folderFilePickerConfig(currentPath) + ) + .show(fragmentManager, null) + fragmentManager.onArkPathPicked(lifecycleOwner) { path -> + currentPath = path + currentPath?.let { + imagePath = it.resolve(name) + showOverwriteCheckbox.value = Files.list(it).toList() + .contains(imagePath) + if (showOverwriteCheckbox.value) { + name = it.findNotExistCopyName( + imagePath?.fileName!! + ).name + } + } + } + } + ) { + Text( + text = currentPath?.toString() + ?: stringResource(R.string.pick_folder) + ) + } + OutlinedTextField( + modifier = Modifier.padding(5.dp), + value = name, + onValueChange = { + name = it + if (name.isEmpty()) { + context.toast( + R.string.ark_retouch_notify_missing_file_name + ) + return@OutlinedTextField + } + currentPath?.let { path -> + imagePath = path.resolve(name) + showOverwriteCheckbox.value = Files.list(path).toList() + .contains(imagePath) + if (showOverwriteCheckbox.value) { + name = path.findNotExistCopyName( + imagePath?.fileName!! + ).name + } + } + }, + label = { Text(text = stringResource(R.string.name)) }, + singleLine = true + ) + if (showOverwriteCheckbox.value) { + Row( + modifier = Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(5)) + .clickable { + overwriteOriginalPath = !overwriteOriginalPath + } + .padding(5.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Checkbox( + checked = overwriteOriginalPath, + onCheckedChange = { + overwriteOriginalPath = !overwriteOriginalPath + } + ) + Text(text = stringResource(R.string.overwrite_original_file)) + } + } + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.End + ) { + TextButton( + modifier = Modifier.padding(5.dp), + onClick = onDismissClick, + colors = ButtonDefaults.textButtonColors( + contentColor = ArkColorPalette.primary + ) + ) { + Text(text = stringResource(R.string.cancel)) + } + Button( + modifier = Modifier.padding(5.dp), + onClick = { + if (name.isEmpty()) { + context.toast( + R.string.ark_retouch_notify_missing_file_name + ) + return@Button + } + if (currentPath == null) { + context.toast( + R.string.ark_retouch_notify_choose_folder + ) + return@Button + } + onPositiveClick(currentPath?.resolve(name)!!) + }, + colors = ButtonDefaults.buttonColors( + backgroundColor = ArkColorPalette.primary + ) + ) { + Text(text = stringResource(R.string.ok)) + } + } + } + } + } +} + +@Composable +fun SaveProgress() { + Dialog(onDismissRequest = {}) { + Box( + Modifier + .fillMaxSize(), + contentAlignment = Alignment.Center + ) { + CircularProgressIndicator( + Modifier.size(40.dp) + ) + } + } +} + +fun folderFilePickerConfig(initialPath: Path?) = ArkFilePickerConfig( + mode = ArkFilePickerMode.FOLDER, + initialPath = initialPath, + showRoots = true, + rootsFirstPage = true +) diff --git a/canvas/src/main/java/dev/arkbuilders/canvas/presentation/edit/TransparencyChessBoard.kt b/canvas/src/main/java/dev/arkbuilders/canvas/presentation/edit/TransparencyChessBoard.kt new file mode 100644 index 0000000..f0a7b99 --- /dev/null +++ b/canvas/src/main/java/dev/arkbuilders/canvas/presentation/edit/TransparencyChessBoard.kt @@ -0,0 +1,91 @@ +package dev.arkbuilders.canvas.presentation.edit + +import android.graphics.Matrix +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.geometry.Rect +import androidx.compose.ui.geometry.Size +import androidx.compose.foundation.Canvas +import androidx.compose.foundation.background +import androidx.compose.ui.graphics.Canvas +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.Paint +import androidx.compose.ui.graphics.drawscope.drawIntoCanvas +import androidx.compose.ui.graphics.nativeCanvas +import androidx.compose.ui.unit.toSize +import dev.arkbuilders.canvas.presentation.drawing.EditManager + +private class TransparencyChessBoard { + fun create(boardSize: Size, canvas: Canvas, matrix: Matrix) { + val numberOfBoxesOnHeight = (boardSize.height / SQUARE_SIZE).toInt() + val numberOfBoxesOnWidth = (boardSize.width / SQUARE_SIZE).toInt() + var color = DARK + val paint = Paint().also { + it.color = color + } + val width = SQUARE_SIZE * (numberOfBoxesOnWidth + 1) + val height = SQUARE_SIZE * (numberOfBoxesOnHeight + 1) + val widthDelta = width - boardSize.width + val heightDelta = height - boardSize.height + canvas.nativeCanvas.setMatrix(matrix) + 0.rangeTo(numberOfBoxesOnWidth).forEach { i -> + 0.rangeTo(numberOfBoxesOnHeight).forEach { j -> + var rectWidth = SQUARE_SIZE + var rectHeight = SQUARE_SIZE + val offsetX = SQUARE_SIZE * i + val offsetY = SQUARE_SIZE * j + if (i == numberOfBoxesOnWidth && widthDelta > 0) { + rectWidth = SQUARE_SIZE - widthDelta + } + if (j == numberOfBoxesOnHeight && heightDelta > 0) { + rectHeight = SQUARE_SIZE - heightDelta + } + val offset = Offset(offsetX, offsetY) + val box = Rect(offset, Size(rectWidth, rectHeight)) + if (j == 0) { + if (color == paint.color) { + switchPaintColor(paint) + } + color = paint.color + } + switchPaintColor(paint) + canvas.drawRect(box, paint) + } + } + } + + private fun switchPaintColor(paint: Paint) { + if (paint.color == DARK) + paint.color = LIGHT + else paint.color = DARK + } + + companion object { + private const val SQUARE_SIZE = 100f + private val LIGHT = Color.White + private val DARK = Color.LightGray + } +} + +private fun transparencyChessBoard( + canvas: Canvas, + size: Size, + matrix: Matrix +) { + TransparencyChessBoard().create(size, canvas, matrix) +} + +@Composable +fun TransparencyChessBoardCanvas(modifier: Modifier, editManager: EditManager) { + Canvas(modifier.background(Color.Transparent)) { + editManager.invalidatorTick.value + drawIntoCanvas { canvas -> + transparencyChessBoard( + canvas, + editManager.imageSize.toSize(), + editManager.backgroundMatrix + ) + } + } +} diff --git a/canvas/src/main/java/dev/arkbuilders/canvas/presentation/edit/blur/BlurIntensityPopup.kt b/canvas/src/main/java/dev/arkbuilders/canvas/presentation/edit/blur/BlurIntensityPopup.kt new file mode 100644 index 0000000..6c68eaa --- /dev/null +++ b/canvas/src/main/java/dev/arkbuilders/canvas/presentation/edit/blur/BlurIntensityPopup.kt @@ -0,0 +1,56 @@ +package dev.arkbuilders.canvas.presentation.edit.blur + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.material.Slider +import androidx.compose.material.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 dev.arkbuilders.canvas.R +import dev.arkbuilders.canvas.presentation.drawing.EditManager + +@Composable +fun BlurIntensityPopup( + editManager: EditManager +) { + if (editManager.isBlurMode.value) { + Column( + Modifier + .fillMaxWidth() + .height(150.dp) + .padding(8.dp), + horizontalAlignment = Alignment.CenterHorizontally + ) { + Column { + Text(stringResource(R.string.blur_intensity)) + Slider( + modifier = Modifier + .fillMaxWidth(), + value = editManager.blurIntensity.value, + onValueChange = { + editManager.blurIntensity.value = it + }, + valueRange = 0f..25f, + ) + } + Column { + Text(stringResource(R.string.blur_size)) + Slider( + modifier = Modifier + .fillMaxWidth(), + value = editManager.blurOperation.blurSize.value, + onValueChange = { + editManager.blurOperation.blurSize.value = it + editManager.blurOperation.resize() + }, + valueRange = 100f..500f, + ) + } + } + } +} diff --git a/canvas/src/main/java/dev/arkbuilders/canvas/presentation/edit/blur/BlurOperation.kt b/canvas/src/main/java/dev/arkbuilders/canvas/presentation/edit/blur/BlurOperation.kt new file mode 100644 index 0000000..9ba6c1a --- /dev/null +++ b/canvas/src/main/java/dev/arkbuilders/canvas/presentation/edit/blur/BlurOperation.kt @@ -0,0 +1,183 @@ +package dev.arkbuilders.canvas.presentation.edit.blur + +import android.content.Context +import android.graphics.Bitmap +import androidx.compose.runtime.mutableStateOf +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.graphics.Canvas +import androidx.compose.ui.graphics.ImageBitmap +import androidx.compose.ui.graphics.ImageBitmapConfig +import androidx.compose.ui.graphics.Paint +import androidx.compose.ui.graphics.asAndroidBitmap +import androidx.compose.ui.graphics.asImageBitmap +import androidx.compose.ui.unit.IntOffset +import com.hoko.blur.processor.HokoBlurBuild +import dev.arkbuilders.canvas.presentation.drawing.EditManager +import dev.arkbuilders.canvas.presentation.edit.Operation +import java.util.Stack + +class BlurOperation(private val editManager: EditManager) : Operation { + private lateinit var blurredBitmap: Bitmap + private lateinit var brushBitmap: Bitmap + private lateinit var context: Context + private val blurs = Stack() + private val redoBlurs = Stack() + private var offset = Offset.Zero + private var bitmapPosition = IntOffset.Zero + + val blurSize = mutableStateOf(BRUSH_SIZE.toFloat()) + + fun init() { + editManager.apply { + backgroundImage.value?.let { + bitmapPosition = IntOffset( + (it.width / 2) - (blurSize.value.toInt() / 2), + (it.height / 2) - (blurSize.value.toInt() / 2) + ) + brushBitmap = Bitmap.createBitmap( + it.asAndroidBitmap(), + bitmapPosition.x, + bitmapPosition.y, + blurSize.value.toInt(), + blurSize.value.toInt() + ) + } + scaleToFitOnEdit() + } + } + + fun resize() { + editManager.backgroundImage.value?.let { + if (isWithinBounds(it)) { + brushBitmap = Bitmap.createBitmap( + it.asAndroidBitmap(), + bitmapPosition.x, + bitmapPosition.y, + blurSize.value.toInt(), + blurSize.value.toInt() + ) + } + } + } + + fun draw(context: Context, canvas: Canvas) { + if (blurSize.value in MIN_SIZE..MAX_SIZE) { + editManager.backgroundImage.value?.let { + this.context = context + if (isWithinBounds(it)) { + offset = Offset( + bitmapPosition.x.toFloat(), + bitmapPosition.y.toFloat() + ) + } + blur(context) + canvas.drawImage( + blurredBitmap.asImageBitmap(), + offset, + Paint() + ) + } + } + } + + fun move(blurPosition: Offset, delta: Offset) { + val position = Offset( + blurPosition.x * editManager.bitmapScale.x, + blurPosition.y * editManager.bitmapScale.y + ) + if (isBrushTouched(position)) { + editManager.apply { + bitmapPosition = IntOffset( + (offset.x + delta.x).toInt(), + (offset.y + delta.y).toInt() + ) + backgroundImage.value?.let { + if (isWithinBounds(it)) { + brushBitmap = Bitmap.createBitmap( + it.asAndroidBitmap(), + bitmapPosition.x, + bitmapPosition.y, + blurSize.value.toInt(), + blurSize.value.toInt() + ) + } + } + } + } + } + + fun clear() { + blurs.clear() + redoBlurs.clear() + editManager.updateRevised() + } + + fun cancel() { + editManager.redrawBackgroundImage2() + } + + private fun isWithinBounds(image: ImageBitmap) = bitmapPosition.x >= 0 && + (bitmapPosition.x + blurSize.value) <= image.width && + bitmapPosition.y >= 0 && (bitmapPosition.y + blurSize.value) <= image.height + + private fun isBrushTouched(position: Offset): Boolean { + return position.x >= offset.x && position.x <= (offset.x + blurSize.value) && + position.y >= offset.y && position.y <= (offset.y + blurSize.value) + } + + override fun apply() { + val image = ImageBitmap( + editManager.imageSize.width, + editManager.imageSize.height, + ImageBitmapConfig.Argb8888 + ) + editManager.backgroundImage.value?.let { + val canvas = Canvas(image) + canvas.drawImage( + it, + Offset.Zero, + Paint() + ) + canvas.drawImage( + blurredBitmap.asImageBitmap(), + offset, + Paint() + ) + blurs.add(editManager.backgroundImage2.value) + editManager.addBlur() + } + editManager.keepEditedPaths() + editManager.toggleBlurMode() + editManager.backgroundImage.value = image + } + + override fun undo() { + val bitmap = blurs.pop() + redoBlurs.push(editManager.backgroundImage.value) + editManager.backgroundImage.value = bitmap + editManager.redrawEditedPaths() + } + + override fun redo() { + val bitmap = redoBlurs.pop() + blurs.push(editManager.backgroundImage.value) + editManager.backgroundImage.value = bitmap + editManager.keepEditedPaths() + } + + private fun blur(context: Context) { + editManager.apply { + val blurProcessor = HokoBlurBuild(context) + .radius(blurIntensity.value.toInt()) + .processor() + blurredBitmap = + blurProcessor.blur(brushBitmap) + } + } + + companion object { + private const val BRUSH_SIZE = 250 + const val MAX_SIZE = 500f + const val MIN_SIZE = 100f + } +} diff --git a/canvas/src/main/java/dev/arkbuilders/canvas/presentation/edit/crop/CropAspectRatiosMenu.kt b/canvas/src/main/java/dev/arkbuilders/canvas/presentation/edit/crop/CropAspectRatiosMenu.kt new file mode 100644 index 0000000..cc6ba13 --- /dev/null +++ b/canvas/src/main/java/dev/arkbuilders/canvas/presentation/edit/crop/CropAspectRatiosMenu.kt @@ -0,0 +1,241 @@ +package dev.arkbuilders.canvas.presentation.edit.crop + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.wrapContentHeight +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.Icon +import androidx.compose.material.MaterialTheme +import androidx.compose.material.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.MutableState +import androidx.compose.runtime.mutableStateOf +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.draw.rotate +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.res.vectorResource +import androidx.compose.ui.unit.dp +import dev.arkbuilders.canvas.R +import dev.arkbuilders.canvas.presentation.edit.crop.AspectRatio.aspectRatios +import dev.arkbuilders.canvas.presentation.edit.crop.AspectRatio.isChanged +import dev.arkbuilders.canvas.presentation.edit.crop.AspectRatio.isCropFree +import dev.arkbuilders.canvas.presentation.edit.crop.AspectRatio.isCropSquare +import dev.arkbuilders.canvas.presentation.edit.crop.AspectRatio.isCrop_9_16 +import dev.arkbuilders.canvas.presentation.edit.crop.AspectRatio.isCrop_2_3 +import dev.arkbuilders.canvas.presentation.edit.crop.AspectRatio.isCrop_4_5 + +@Composable +fun CropAspectRatiosMenu( + isVisible: Boolean = false, + cropWindow: CropWindow +) { + if (isVisible) { + if (isChanged.value) { + cropWindow.resize() + isChanged.value = false + } + Row( + Modifier + .fillMaxWidth() + .wrapContentHeight(), + horizontalArrangement = Arrangement.Center + ) { + Column( + Modifier + .clip(RoundedCornerShape(5.dp)) + .clickable { + switchAspectRatio(isCropFree) + } + ) { + Icon( + modifier = Modifier + .padding(start = 12.dp, end = 12.dp, bottom = 5.dp) + .align(Alignment.CenterHorizontally) + .size(30.dp), + imageVector = + ImageVector.vectorResource(id = R.drawable.ic_crop), + contentDescription = + stringResource(id = R.string.ark_retouch_crop_free), + tint = if (isCropFree.value) + MaterialTheme.colors.primary + else Color.Black + ) + Text( + text = stringResource(R.string.ark_retouch_crop_free), + modifier = Modifier + .align(Alignment.CenterHorizontally), + color = if (isCropFree.value) + MaterialTheme.colors.primary + else Color.Black + ) + } + Column( + Modifier + .clip(RoundedCornerShape(5.dp)) + .clickable { + switchAspectRatio(isCropSquare) + } + ) { + Icon( + modifier = Modifier + .padding(start = 12.dp, end = 12.dp, bottom = 5.dp) + .align(Alignment.CenterHorizontally) + .size(30.dp), + imageVector = + ImageVector.vectorResource(id = R.drawable.ic_crop_square), + contentDescription = + stringResource(id = R.string.ark_retouch_crop_square), + tint = if (isCropSquare.value) + MaterialTheme.colors.primary + else Color.Black + ) + Text( + stringResource(R.string.ark_retouch_crop_square), + modifier = Modifier.align(Alignment.CenterHorizontally), + color = if (isCropSquare.value) + MaterialTheme.colors.primary + else Color.Black + ) + } + Column( + Modifier + .clip(RoundedCornerShape(5.dp)) + .clickable { + switchAspectRatio(isCrop_4_5) + } + ) { + Icon( + modifier = Modifier + .padding( + start = 12.dp, end = 12.dp, + top = 5.dp, bottom = 5.dp + ) + .rotate(90f) + .align(Alignment.CenterHorizontally) + .size(30.dp), + imageVector = + ImageVector.vectorResource(id = R.drawable.ic_crop_5_4), + contentDescription = + stringResource(id = R.string.ark_retouch_crop_4_5), + tint = if (isCrop_4_5.value) + MaterialTheme.colors.primary + else Color.Black + ) + Text( + stringResource(R.string.ark_retouch_crop_4_5), + modifier = Modifier.align(Alignment.CenterHorizontally), + color = if (isCrop_4_5.value) + MaterialTheme.colors.primary + else Color.Black + ) + } + Column( + Modifier + .clip(RoundedCornerShape(5.dp)) + .clickable { + switchAspectRatio(isCrop_9_16) + } + ) { + Icon( + modifier = Modifier + .padding( + start = 12.dp, end = 12.dp, + top = 5.dp, bottom = 5.dp + ) + .rotate(90f) + .align(Alignment.CenterHorizontally) + .size(30.dp), + imageVector = + ImageVector.vectorResource(id = R.drawable.ic_crop_16_9), + contentDescription = + stringResource(id = R.string.ark_retouch_crop_9_16), + tint = if (isCrop_9_16.value) + MaterialTheme.colors.primary + else Color.Black + ) + Text( + stringResource(R.string.ark_retouch_crop_9_16), + modifier = Modifier.align(Alignment.CenterHorizontally), + color = if (isCrop_9_16.value) + MaterialTheme.colors.primary + else Color.Black + ) + } + Column( + Modifier + .clip(RoundedCornerShape(5.dp)) + .clickable { + switchAspectRatio(isCrop_2_3) + } + ) { + Icon( + modifier = Modifier + .padding( + start = 12.dp, end = 12.dp, + top = 5.dp, bottom = 5.dp + ) + .rotate(90f) + .align(Alignment.CenterHorizontally) + .size(30.dp), + imageVector = + ImageVector.vectorResource(id = R.drawable.ic_crop_3_2), + contentDescription = + stringResource(id = R.string.ark_retouch_crop_2_3), + tint = if (isCrop_2_3.value) + MaterialTheme.colors.primary + else Color.Black + ) + Text( + stringResource(R.string.ark_retouch_crop_2_3), + modifier = Modifier.align(Alignment.CenterHorizontally), + color = if (isCrop_2_3.value) + MaterialTheme.colors.primary + else Color.Black + ) + } + } + } else switchAspectRatio(isCropFree) +} + +internal fun switchAspectRatio(selected: MutableState) { + selected.value = true + aspectRatios.filter { + it != selected + }.forEach { + it.value = false + } + isChanged.value = true +} + +internal object AspectRatio { + val isCropFree = mutableStateOf(false) + val isCropSquare = mutableStateOf(false) + val isCrop_4_5 = mutableStateOf(false) + val isCrop_9_16 = mutableStateOf(false) + val isCrop_2_3 = mutableStateOf(false) + val isChanged = mutableStateOf(false) + + val aspectRatios = listOf( + isCropFree, + isCropSquare, + isCrop_4_5, + isCrop_9_16, + isCrop_2_3 + ) + + val CROP_FREE = Offset(0f, 0f) + val CROP_SQUARE = Offset(1f, 1f) + val CROP_4_5 = Offset(4f, 5f) + val CROP_9_16 = Offset(9f, 16f) + val CROP_2_3 = Offset(2f, 3f) +} diff --git a/canvas/src/main/java/dev/arkbuilders/canvas/presentation/edit/crop/CropOperation.kt b/canvas/src/main/java/dev/arkbuilders/canvas/presentation/edit/crop/CropOperation.kt new file mode 100644 index 0000000..15c7c49 --- /dev/null +++ b/canvas/src/main/java/dev/arkbuilders/canvas/presentation/edit/crop/CropOperation.kt @@ -0,0 +1,53 @@ +package dev.arkbuilders.canvas.presentation.edit.crop + +import androidx.compose.ui.graphics.asImageBitmap +import dev.arkbuilders.canvas.presentation.drawing.EditManager +import dev.arkbuilders.canvas.presentation.edit.Operation +import dev.arkbuilders.canvas.presentation.utils.crop + +class CropOperation( + private val editManager: EditManager +) : Operation { + + override fun apply() { + editManager.apply { + cropWindow.apply { + val image = getBitmap().crop(getCropParams()).asImageBitmap() + backgroundImage.value = image + keepEditedPaths() + addCrop() + saveRotationAfterOtherOperation() + scaleToFit() + toggleCropMode() + } + } + } + + override fun undo() { + editManager.apply { + if (cropStack.isNotEmpty()) { + val image = cropStack.pop() + redoCropStack.push(backgroundImage.value) + backgroundImage.value = image + restoreRotationAfterUndoOtherOperation() + scaleToFit() + redrawEditedPaths() + updateRevised() + } + } + } + + override fun redo() { + editManager.apply { + if (redoCropStack.isNotEmpty()) { + val image = redoCropStack.pop() + cropStack.push(backgroundImage.value) + backgroundImage.value = image + saveRotationAfterOtherOperation() + scaleToFit() + keepEditedPaths() + updateRevised() + } + } + } +} diff --git a/canvas/src/main/java/dev/arkbuilders/canvas/presentation/edit/crop/CropWindow.kt b/canvas/src/main/java/dev/arkbuilders/canvas/presentation/edit/crop/CropWindow.kt new file mode 100644 index 0000000..613f5d0 --- /dev/null +++ b/canvas/src/main/java/dev/arkbuilders/canvas/presentation/edit/crop/CropWindow.kt @@ -0,0 +1,443 @@ +package dev.arkbuilders.canvas.presentation.edit.crop + +import android.graphics.Bitmap +import androidx.compose.runtime.mutableStateOf +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.geometry.Rect +import androidx.compose.ui.graphics.Canvas +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.Paint +import androidx.compose.ui.graphics.PaintingStyle +import androidx.compose.ui.unit.IntSize +import dev.arkbuilders.canvas.presentation.drawing.EditManager +import dev.arkbuilders.canvas.presentation.edit.crop.AspectRatio.CROP_2_3 +import dev.arkbuilders.canvas.presentation.edit.crop.AspectRatio.CROP_4_5 +import dev.arkbuilders.canvas.presentation.edit.crop.AspectRatio.CROP_9_16 +import dev.arkbuilders.canvas.presentation.edit.crop.AspectRatio.CROP_SQUARE +import dev.arkbuilders.canvas.presentation.edit.crop.AspectRatio.isCropFree +import dev.arkbuilders.canvas.presentation.edit.crop.AspectRatio.isCropSquare +import dev.arkbuilders.canvas.presentation.edit.crop.AspectRatio.isCrop_2_3 +import dev.arkbuilders.canvas.presentation.edit.crop.AspectRatio.isCrop_4_5 +import dev.arkbuilders.canvas.presentation.edit.crop.AspectRatio.isCrop_9_16 +import dev.arkbuilders.canvas.presentation.edit.resize.ResizeOperation +import timber.log.Timber + +class CropWindow(private val editManager: EditManager) { + + private lateinit var bitmap: Bitmap + + private var offset = Offset(0f, 0f) + + private var aspectRatio = 1F + + private var width: Float = MIN_WIDTH + private var height: Float = MIN_HEIGHT + private var cropAreaWidth: Float = MIN_WIDTH + private var cropAreaHeight: Float = MIN_HEIGHT + private lateinit var matrixScale: ResizeOperation.Scale + private lateinit var cropScale: ResizeOperation.Scale + private lateinit var rectScale: ResizeOperation.Scale + + private val drawAreaSize: IntSize + get() { + return editManager.drawAreaSize.value + } + + private lateinit var rect: Rect + + private val isTouchedRight = mutableStateOf(false) + private val isTouchedLeft = mutableStateOf(false) + private val isTouchedTop = mutableStateOf(false) + private val isTouchedBottom = mutableStateOf(false) + private val isTouchedInside = mutableStateOf(false) + private val isTouched = mutableStateOf(false) + private val isTouchedTopLeft = mutableStateOf(false) + private val isTouchedBottomLeft = mutableStateOf(false) + private val isTouchedTopRight = mutableStateOf(false) + private val isTouchedBottomRight = mutableStateOf(false) + private val isTouchedOnCorner = mutableStateOf(false) + + private var delta = Offset( + 0F, + 0F + ) + + private val paint = Paint() + + private var isInitialized = false + + init { + paint.color = Color.LightGray + paint.style = PaintingStyle.Stroke + paint.strokeWidth = 5F + } + + private fun calcMaxDimens() { + width = drawAreaSize.width.toFloat() - HORIZONTAL_OFFSET + height = drawAreaSize.height.toFloat() - VERTICAL_OFFSET + } + + private fun calcOffset() { + val x = ((drawAreaSize.width - cropAreaWidth) / 2f) + .coerceAtLeast(0f) + val y = ((drawAreaSize.height - cropAreaHeight) / 2f) + .coerceAtLeast(0f) + offset = Offset(x, y) + } + + fun updateOnDrawAreaSizeChange(newSize: IntSize) { + reInit() + updateOnOffsetChange() + } + + fun close() { + isInitialized = false + } + + fun init( + bitmap: Bitmap + ) { + if (!isInitialized) { + Timber.tag("crop-window").d("Initialising") + this.bitmap = bitmap + calcMaxDimens() + val viewParams = editManager.scaleToFitOnEdit( + width.toInt(), + height.toInt() + ) + matrixScale = viewParams.scale + cropAreaWidth = viewParams.drawArea.width.toFloat() + cropAreaHeight = viewParams.drawArea.height.toFloat() + cropScale = ResizeOperation.Scale( + bitmap.width / cropAreaWidth, + bitmap.height / cropAreaHeight + ) + calcOffset() + isInitialized = true + } + } + + private fun reInit() { + calcMaxDimens() + val viewParams = editManager.scaleToFitOnEdit( + width.toInt(), + height.toInt() + ) + val prevCropAreaWidth = cropAreaWidth + val prevCropAreaHeight = cropAreaHeight + matrixScale = viewParams.scale + cropAreaWidth = viewParams.drawArea.width.toFloat() + cropAreaHeight = viewParams.drawArea.height.toFloat() + cropScale = ResizeOperation.Scale( + bitmap.width / cropAreaWidth, + bitmap.height / cropAreaHeight + ) + rectScale = ResizeOperation.Scale( + prevCropAreaWidth / cropAreaWidth, + prevCropAreaHeight / cropAreaHeight + ) + } + + private fun updateOnOffsetChange() { + val leftMove = rect.left - offset.x + val topMove = rect.top - offset.y + calcOffset() + val newLeft = offset.x + (leftMove / rectScale.x) + val newTop = offset.y + (topMove / rectScale.y) + val newRight = newLeft + (rect.width / rectScale.x) + val newBottom = newTop + (rect.height / rectScale.y) + create( + newLeft, + newTop, + newRight, + newBottom + ) + } + + fun show(canvas: Canvas) { + if (isInitialized) { + update() + } + draw(canvas) + } + + fun setDelta(delta: Offset) { + this.delta = delta + } + + private fun isAspectRatioFixed() = + isCropSquare.value || isCrop_4_5.value || + isCrop_9_16.value || isCrop_2_3.value + + private fun update() { + var left = rect.left + var right = rect.right + var top = rect.top + var bottom = rect.bottom + + if (isAspectRatioFixed()) { + if (isTouchedOnCorner.value) { + if (isTouchedTopLeft.value) { + left = rect.left + delta.x + top = rect.top + delta.y + } + if (isTouchedTopRight.value) { + right = rect.right + delta.x + top = rect.top - delta.y + } + if (isTouchedBottomLeft.value) { + left = rect.left + delta.x + bottom = rect.bottom - delta.y + } + if (isTouchedBottomRight.value) { + right = rect.right + delta.x + bottom = rect.bottom + delta.y + } + val newHeight = (right - left) * aspectRatio + if (isTouchedTopLeft.value || isTouchedTopRight.value) + top = bottom - newHeight + if (isTouchedBottomLeft.value || isTouchedBottomRight.value) + bottom = top + newHeight + } else { + if (isTouchedLeft.value) { + left = rect.left + delta.x + top = rect.top + ((delta.x * aspectRatio) / 2f) + bottom = rect.bottom - ((delta.x * aspectRatio) / 2f) + } + if (isTouchedRight.value) { + right = rect.right + delta.x + top = rect.top - ((delta.x * aspectRatio) / 2f) + bottom = rect.bottom + ((delta.x * aspectRatio) / 2f) + } + if (isTouchedTop.value) { + top = rect.top + delta.y + left = rect.left + ((delta.y * (1f / aspectRatio)) / 2f) + right = rect.right - ((delta.y * (1f / aspectRatio)) / 2f) + } + if (isTouchedBottom.value) { + bottom = rect.bottom + delta.y + left = rect.left - ((delta.y * (1f / aspectRatio)) / 2f) + right = rect.right + ((delta.y * (1f / aspectRatio)) / 2f) + } + } + } else { + left = if (isTouchedLeft.value) + rect.left + delta.x + else rect.left + right = if (isTouchedRight.value) + rect.right + delta.x + else rect.right + top = if (isTouchedTop.value) + rect.top + delta.y + else rect.top + bottom = if (isTouchedBottom.value) + rect.bottom + delta.y + else rect.bottom + } + + fun isNotMinSize() = (right - left) >= MIN_WIDTH && + (bottom - top) >= MIN_HEIGHT + + fun isNotMaxSize() = (right - left) <= cropAreaWidth && + (bottom - top) <= cropAreaHeight + + fun isWithinBounds() = left >= offset.x && + right <= offset.x + cropAreaWidth && + top >= offset.y && + bottom <= offset.y + cropAreaHeight + + if (isTouchedInside.value) { + right += delta.x + left += delta.x + top += delta.y + bottom += delta.y + if (left < offset.x) { + left = offset.x + right = left + rect.width + } + if (right > offset.x + cropAreaWidth) { + right = offset.x + cropAreaWidth + left = right - rect.width + } + if (top < offset.y) { + top = offset.y + bottom = top + rect.height + } + if (bottom > offset.y + cropAreaHeight) { + bottom = offset.y + cropAreaHeight + top = bottom - rect.height + } + } + + if (isNotMaxSize() && isNotMinSize() && isWithinBounds()) { + create( + left, + top, + right, + bottom + ) + } + } + + fun detectTouchedSide(eventPoint: Offset) { + isTouchedLeft.value = eventPoint.x >= + (rect.left - SIDE_DETECTOR_TOLERANCE) && + eventPoint.x <= (rect.left + SIDE_DETECTOR_TOLERANCE) + isTouchedRight.value = eventPoint.x >= + (rect.right - SIDE_DETECTOR_TOLERANCE) && + eventPoint.x <= (rect.right + SIDE_DETECTOR_TOLERANCE) + isTouchedTop.value = eventPoint.y >= + (rect.top - SIDE_DETECTOR_TOLERANCE) && + eventPoint.y <= (rect.top + SIDE_DETECTOR_TOLERANCE) + isTouchedBottom.value = eventPoint.y >= + (rect.bottom - SIDE_DETECTOR_TOLERANCE) && + eventPoint.y <= (rect.bottom + SIDE_DETECTOR_TOLERANCE) + isTouchedInside.value = eventPoint.x >= + rect.left + SIDE_DETECTOR_TOLERANCE && + eventPoint.x <= rect.right - SIDE_DETECTOR_TOLERANCE && + eventPoint.y >= rect.top + SIDE_DETECTOR_TOLERANCE && + eventPoint.y <= rect.bottom - SIDE_DETECTOR_TOLERANCE + isTouchedTopLeft.value = isTouchedLeft.value && isTouchedTop.value + isTouchedTopRight.value = isTouchedTop.value && isTouchedRight.value + isTouchedBottomLeft.value = isTouchedBottom.value && isTouchedLeft.value + isTouchedBottomRight.value = isTouchedBottom.value && isTouchedRight.value + isTouchedOnCorner.value = isTouchedTopLeft.value || + isTouchedTopRight.value || isTouchedBottomLeft.value || + isTouchedBottomRight.value + isTouched.value = isTouchedLeft.value || isTouchedRight.value || + isTouchedTop.value || isTouchedBottom.value || + isTouchedInside.value + } + + fun resize() { + if (isInitialized) { + resizeByAspectRatio() + } + } + + private fun resizeByBitmap() { + val newBottom = cropAreaHeight + offset.y + val newRight = cropAreaWidth + offset.x + create( + offset.x, + offset.y, + newRight, + newBottom + ) + } + + private fun resizeByAspectRatio() { + if (isCropFree.value) { + resizeByBitmap() + } else { + when { + isCropSquare.value -> + aspectRatio = CROP_SQUARE.y / CROP_SQUARE.x + isCrop_4_5.value -> + aspectRatio = CROP_4_5.y / CROP_4_5.x + isCrop_9_16.value -> + aspectRatio = CROP_9_16.y / CROP_9_16.x + isCrop_2_3.value -> + aspectRatio = CROP_2_3.y / CROP_2_3.x + } + computeSize() + } + } + + private fun create( + newLeft: Float, + newTop: Float, + newRight: Float, + newBottom: Float + ) { + rect = Rect( + newLeft, + newTop, + newRight, + newBottom + ) + } + + private fun computeSize() { + var newWidth = cropAreaWidth + var newHeight = cropAreaWidth * aspectRatio + var newLeft = offset.x + var newTop = offset.y + + (cropAreaHeight - newHeight) / 2f + var newRight = newLeft + newWidth + var newBottom = newTop + newHeight + + if (newHeight > cropAreaHeight) { + newHeight = cropAreaHeight + newWidth = newHeight / aspectRatio + newLeft = offset.x + ( + cropAreaWidth - newWidth + ) / 2f + newTop = offset.y + newRight = newLeft + newWidth + newBottom = newTop + newHeight + } + + create( + newLeft, + newTop, + newRight, + newBottom + ) + } + + private fun draw(canvas: Canvas) { + canvas.drawRect( + rect, + paint + ) + } + + fun getCropParams(): CropParams { + val x = ((rect.left - offset.x) * cropScale.x).toInt() + val y = ((rect.top - offset.y) * cropScale.y).toInt() + val width = (rect.width * cropScale.x).toInt() + val height = (rect.height * cropScale.y).toInt() + return CropParams.create( + x, + y, + width, + height + ) + } + + fun getBitmap() = bitmap + + companion object { + private const val HORIZONTAL_OFFSET = 150F + private const val VERTICAL_OFFSET = 220F + private const val SIDE_DETECTOR_TOLERANCE = 50 + private const val MIN_WIDTH = 150F + private const val MIN_HEIGHT = 150F + + fun computeDeltaX(initialX: Float, currentX: Float) = currentX - initialX + + fun computeDeltaY(initialY: Float, currentY: Float) = currentY - initialY + } + + class CropParams private constructor( + val x: Int, + val y: Int, + val width: Int, + val height: Int + ) { + companion object { + fun create( + x: Int, + y: Int, + width: Int, + height: Int + ) = CropParams( + x, + y, + width, + height + ) + } + } +} diff --git a/canvas/src/main/java/dev/arkbuilders/canvas/presentation/edit/draw/DrawOperation.kt b/canvas/src/main/java/dev/arkbuilders/canvas/presentation/edit/draw/DrawOperation.kt new file mode 100644 index 0000000..34b7128 --- /dev/null +++ b/canvas/src/main/java/dev/arkbuilders/canvas/presentation/edit/draw/DrawOperation.kt @@ -0,0 +1,37 @@ +package dev.arkbuilders.canvas.presentation.edit.draw + +import androidx.compose.ui.graphics.Path +import dev.arkbuilders.canvas.presentation.drawing.EditManager +import dev.arkbuilders.canvas.presentation.edit.Operation + +class DrawOperation(private val editManager: EditManager) : Operation { + private var path = Path() + + override fun apply() { + editManager.addDrawPath(path) + } + + override fun undo() { + editManager.apply { + if (drawPaths.isNotEmpty()) { + redoPaths.push(drawPaths.pop()) + updateRevised() + return + } + } + } + + override fun redo() { + editManager.apply { + if (redoPaths.isNotEmpty()) { + drawPaths.push(redoPaths.pop()) + updateRevised() + return + } + } + } + + fun draw(path: Path) { + this.path = path + } +} diff --git a/canvas/src/main/java/dev/arkbuilders/canvas/presentation/edit/resize/ResizeInput.kt b/canvas/src/main/java/dev/arkbuilders/canvas/presentation/edit/resize/ResizeInput.kt new file mode 100644 index 0000000..e7b9fe6 --- /dev/null +++ b/canvas/src/main/java/dev/arkbuilders/canvas/presentation/edit/resize/ResizeInput.kt @@ -0,0 +1,201 @@ +package dev.arkbuilders.canvas.presentation.edit.resize + +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.core.tween +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.wrapContentSize +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.material.MaterialTheme +import androidx.compose.material.Text +import androidx.compose.material.TextField +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.setValue +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.saveable.rememberSaveable +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.TextStyle +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import androidx.core.text.isDigitsOnly +import dev.arkbuilders.canvas.R +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch +import dev.arkbuilders.canvas.presentation.drawing.EditManager + +@Composable +fun ResizeInput(isVisible: Boolean, editManager: EditManager) { + if (isVisible) { + var width by rememberSaveable { + mutableStateOf( + editManager.imageSize.width.toString() + ) + } + + var height by rememberSaveable { + mutableStateOf( + editManager.imageSize.height.toString() + ) + } + + val widthHint = stringResource( + R.string.width_too_large, + editManager.imageSize.width + ) + val digitsHint = stringResource(R.string.digits_only) + val heightHint = stringResource( + R.string.height_too_large, + editManager.imageSize.height + ) + var hint by remember { + mutableStateOf("") + } + var showHint by remember { + mutableStateOf(false) + } + + Column( + horizontalAlignment = Alignment.CenterHorizontally + ) { + Hint( + hint, + isVisible = { + delayHidingHint(it) { + showHint = false + } + showHint + } + ) + Row { + TextField( + modifier = Modifier.fillMaxWidth(0.5f), + value = width, + onValueChange = { + if ( + it.isNotEmpty() && + it.isDigitsOnly() && + it.toInt() > editManager.imageSize.width + ) { + hint = widthHint + showHint = true + return@TextField + } + if (it.isNotEmpty() && !it.isDigitsOnly()) { + hint = digitsHint + showHint = true + return@TextField + } + width = it + showHint = false + if (width.isEmpty()) height = width + if (width.isNotEmpty() && width.isDigitsOnly()) { + height = editManager.resizeDown(width = width.toInt()) + .height.toString() + } + }, + label = { + Text( + stringResource(R.string.width), + modifier = Modifier + .fillMaxWidth(), + color = MaterialTheme.colors.primary, + textAlign = TextAlign.Center + ) + }, + textStyle = TextStyle( + color = Color.DarkGray, + textAlign = TextAlign.Center + ), + singleLine = true, + keyboardOptions = KeyboardOptions( + keyboardType = KeyboardType.Number + ) + ) + TextField( + modifier = Modifier.fillMaxWidth(), + value = height, + onValueChange = { + if ( + it.isNotEmpty() && + it.isDigitsOnly() && + it.toInt() > editManager.imageSize.height + ) { + hint = heightHint + showHint = true + return@TextField + } + if (it.isNotEmpty() && !it.isDigitsOnly()) { + hint = digitsHint + showHint = true + return@TextField + } + height = it + showHint = false + if (height.isEmpty()) width = height + if (height.isNotEmpty() && height.isDigitsOnly()) { + width = editManager.resizeDown(height = height.toInt()) + .width.toString() + } + }, + label = { + Text( + stringResource(R.string.height), + modifier = Modifier + .fillMaxWidth(), + color = MaterialTheme.colors.primary, + textAlign = TextAlign.Center + ) + }, + textStyle = TextStyle( + color = Color.DarkGray, + textAlign = TextAlign.Center + ), + singleLine = true, + keyboardOptions = KeyboardOptions( + keyboardType = KeyboardType.Number + ) + ) + } + } + } +} + +fun delayHidingHint(scope: CoroutineScope, hide: () -> Unit) { + scope.launch { + delay(1000) + hide() + } +} + +@Composable +fun Hint(text: String, isVisible: (CoroutineScope) -> Boolean) { + val scope = rememberCoroutineScope() + AnimatedVisibility( + visible = isVisible(scope), + enter = fadeIn(), + exit = fadeOut(tween(durationMillis = 500, delayMillis = 1000)), + modifier = Modifier + .wrapContentSize() + .background(Color.LightGray, RoundedCornerShape(10)) + ) { + Text( + text, + Modifier + .padding(12.dp) + ) + } +} diff --git a/canvas/src/main/java/dev/arkbuilders/canvas/presentation/edit/resize/ResizeOperation.kt b/canvas/src/main/java/dev/arkbuilders/canvas/presentation/edit/resize/ResizeOperation.kt new file mode 100644 index 0000000..8b96f67 --- /dev/null +++ b/canvas/src/main/java/dev/arkbuilders/canvas/presentation/edit/resize/ResizeOperation.kt @@ -0,0 +1,114 @@ +package dev.arkbuilders.canvas.presentation.edit.resize + +import android.graphics.Bitmap +import androidx.compose.runtime.mutableStateOf +import androidx.compose.ui.graphics.ImageBitmap +import androidx.compose.ui.graphics.asImageBitmap +import androidx.compose.ui.unit.IntSize +import dev.arkbuilders.canvas.presentation.drawing.EditManager +import dev.arkbuilders.canvas.presentation.edit.Operation +import dev.arkbuilders.canvas.presentation.utils.resize +import java.lang.NullPointerException + +class ResizeOperation(private val editManager: EditManager) : Operation { + + private lateinit var bitmap: Bitmap + private var aspectRatio = 1f + private lateinit var editMatrixScale: Scale + private val isApplied = mutableStateOf(false) + + override fun apply() { + editManager.apply { + addResize() + saveRotationAfterOtherOperation() + scaleToFit() + toggleResizeMode() + editMatrix.reset() + isApplied.value = true + } + } + + override fun undo() { + editManager.apply { + if (resizes.isNotEmpty()) { + redoResize.push(backgroundImage.value) + backgroundImage.value = resizes.pop() + restoreRotationAfterUndoOtherOperation() + scaleToFit() + redrawEditedPaths() + } + } + } + + override fun redo() { + editManager.apply { + if (redoResize.isNotEmpty()) { + resizes.push(backgroundImage.value) + saveRotationAfterOtherOperation() + backgroundImage.value = redoResize.pop() + scaleToFit() + keepEditedPaths() + } + } + } + + fun init(bitmap: Bitmap) { + this.bitmap = bitmap + aspectRatio = bitmap.width.toFloat() / bitmap.height.toFloat() + editMatrixScale = editManager.scaleToFitOnEdit().scale + isApplied.value = false + } + + fun updateEditMatrixScale(scale: Scale) { + editMatrixScale = scale + } + + fun isApplied() = isApplied.value + + fun resetApply() { isApplied.value = false } + + fun resizeDown( + width: Int, + height: Int, + updateImage: (ImageBitmap) -> Unit + ): IntSize { + return try { + var newWidth = width + var newHeight = height + if (width > 0) newHeight = ( + newWidth / + aspectRatio + ).toInt() + if (height > 0) + newWidth = (newHeight * aspectRatio).toInt() + if (newWidth > 0 && newHeight > 0) editManager.apply { + if ( + newWidth <= bitmap.width && + newHeight <= bitmap.height + ) { + val sx = newWidth.toFloat() / bitmap.width.toFloat() + val sy = newHeight.toFloat() / bitmap.height.toFloat() + val downScale = Scale(sx, sy) + val imgBitmap = bitmap.resize(downScale).asImageBitmap() + val drawWidth = imgBitmap.width * editMatrixScale.x + val drawHeight = imgBitmap.height * editMatrixScale.y + val drawArea = IntSize(drawWidth.toInt(), drawHeight.toInt()) + updateAvailableDrawArea(drawArea) + updateImage(imgBitmap) + } + } + IntSize( + newWidth, + newHeight + ) + } catch (e: NullPointerException) { + e.printStackTrace() + IntSize.Zero + } + } + + data class Scale( + val x: Float = 1f, + val y: Float = 1f + ) +} diff --git a/canvas/src/main/java/dev/arkbuilders/canvas/presentation/edit/rotate/RotateOperation.kt b/canvas/src/main/java/dev/arkbuilders/canvas/presentation/edit/rotate/RotateOperation.kt new file mode 100644 index 0000000..0bffbb3 --- /dev/null +++ b/canvas/src/main/java/dev/arkbuilders/canvas/presentation/edit/rotate/RotateOperation.kt @@ -0,0 +1,47 @@ +package dev.arkbuilders.canvas.presentation.edit.rotate + +import android.graphics.Matrix +import dev.arkbuilders.canvas.presentation.drawing.EditManager +import dev.arkbuilders.canvas.presentation.edit.Operation +import dev.arkbuilders.canvas.presentation.utils.rotate + +class RotateOperation(private val editManager: EditManager) : Operation { + + override fun apply() { + editManager.apply { + toggleRotateMode() + matrix.set(editMatrix) + editMatrix.reset() + addRotation() + } + } + + override fun undo() { + editManager.apply { + if (rotationAngles.isNotEmpty()) { + redoRotationAngles.push(prevRotationAngle) + prevRotationAngle = rotationAngles.pop() + scaleToFit() + } + } + } + + override fun redo() { + editManager.apply { + if (redoRotationAngles.isNotEmpty()) { + rotationAngles.push(prevRotationAngle) + prevRotationAngle = redoRotationAngles.pop() + scaleToFit() + } + } + } + + fun rotate(matrix: Matrix, angle: Float, px: Float, py: Float) { + matrix.rotate(angle, Center(px, py)) + } + + data class Center( + val x: Float, + val y: Float + ) +} diff --git a/canvas/src/main/java/dev/arkbuilders/canvas/presentation/graphics/ColorCode.kt b/canvas/src/main/java/dev/arkbuilders/canvas/presentation/graphics/ColorCode.kt new file mode 100644 index 0000000..bc9a1a0 --- /dev/null +++ b/canvas/src/main/java/dev/arkbuilders/canvas/presentation/graphics/ColorCode.kt @@ -0,0 +1,13 @@ +package dev.arkbuilders.canvas.presentation.graphics + +internal object ColorCode { + val black by lazy { android.graphics.Color.parseColor("#000000") } + val gray by lazy { android.graphics.Color.parseColor("#667085") } + val red by lazy { android.graphics.Color.parseColor("#F04438") } + val orange by lazy { android.graphics.Color.parseColor("#F79009") } + val green by lazy { android.graphics.Color.parseColor("#17B26A") } + val blue by lazy { android.graphics.Color.parseColor("#0BA5EC") } + val purple by lazy { android.graphics.Color.parseColor("#7A5AF8") } + val white by lazy { android.graphics.Color.parseColor("#FFFFFF") } + val brown by lazy { android.graphics.Color.parseColor("#B54708") } +} \ No newline at end of file diff --git a/canvas/src/main/java/dev/arkbuilders/canvas/presentation/graphics/Size.kt b/canvas/src/main/java/dev/arkbuilders/canvas/presentation/graphics/Size.kt new file mode 100644 index 0000000..43c67ce --- /dev/null +++ b/canvas/src/main/java/dev/arkbuilders/canvas/presentation/graphics/Size.kt @@ -0,0 +1,14 @@ +package dev.arkbuilders.canvas.presentation.graphics + +enum class Size(val id: Int, val value: Float) { + + TINY(0, 5f), + + SMALL(1, 10f), + + MEDIUM(2, 15f), + + LARGE(3, 20f), + + HUGE(4, 25f) +} \ No newline at end of file diff --git a/canvas/src/main/java/dev/arkbuilders/canvas/presentation/picker/FilePickerScreen.kt b/canvas/src/main/java/dev/arkbuilders/canvas/presentation/picker/FilePickerScreen.kt new file mode 100644 index 0000000..cc7b3d6 --- /dev/null +++ b/canvas/src/main/java/dev/arkbuilders/canvas/presentation/picker/FilePickerScreen.kt @@ -0,0 +1,155 @@ +package dev.arkbuilders.canvas.presentation.picker + +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.Icon +import androidx.compose.material.Text +import androidx.compose.runtime.Composable +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.graphics.Color +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.layout.onSizeChanged +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.platform.LocalLifecycleOwner +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.res.vectorResource +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.IntSize +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.fragment.app.FragmentManager +import dev.arkbuilders.canvas.R +import dev.arkbuilders.canvas.presentation.data.Resolution +import dev.arkbuilders.canvas.presentation.utils.askWritePermissions +import dev.arkbuilders.canvas.presentation.utils.isWritePermGranted +import dev.arkbuilders.canvas.presentation.theme.Purple500 +import dev.arkbuilders.canvas.presentation.theme.Purple700 +import dev.arkbuilders.components.filepicker.ArkFilePickerConfig +import dev.arkbuilders.components.filepicker.ArkFilePickerFragment +import dev.arkbuilders.components.filepicker.ArkFilePickerMode +import dev.arkbuilders.components.filepicker.onArkPathPicked +import java.nio.file.Path + +@Composable +fun PickerScreen( + fragmentManager: FragmentManager, + onNavigateToEdit: (Path?, Resolution) -> Unit +) { + val lifecycleOwner = LocalLifecycleOwner.current + val context = LocalContext.current + var size by remember { mutableStateOf(IntSize.Zero) } + var screenSize by remember { mutableStateOf(IntSize.Zero) } + + Column( + modifier = Modifier + .fillMaxSize() + .onSizeChanged { + screenSize = it + } + ) { + Column( + modifier = Modifier + .weight(2f) + .fillMaxWidth() + .padding(20.dp) + .onSizeChanged { + size = it + } + .clip(RoundedCornerShape(10)) + .background(Purple500) + .clickable { + if (!context.isWritePermGranted()) { + context.askWritePermissions() + return@clickable + } + + ArkFilePickerFragment + .newInstance(imageFilePickerConfig()) + .show(fragmentManager, null) + fragmentManager.onArkPathPicked(lifecycleOwner) { + onNavigateToEdit(it, Resolution.fromIntSize(screenSize)) + } + } + .border(2.dp, Purple700, shape = RoundedCornerShape(10)), + verticalArrangement = Arrangement.SpaceEvenly, + horizontalAlignment = Alignment.CenterHorizontally + ) { + Text( + text = stringResource(R.string.open), + fontSize = 24.sp, + color = Color.White + ) + Icon( + modifier = Modifier.size(size.height.toDp() / 2), + imageVector = ImageVector.vectorResource(R.drawable.ic_insert_photo), + tint = Color.White, + contentDescription = null + ) + } + Text( + modifier = Modifier + .align(Alignment.CenterHorizontally), + text = stringResource(R.string.or), + fontSize = 24.sp + ) + Column( + Modifier + .weight(2f) + .fillMaxWidth() + .padding(20.dp) + .clip(RoundedCornerShape(10)) + .background(Purple500) + .fillMaxWidth() + .clickable { + onNavigateToEdit(null, Resolution.fromIntSize(screenSize)) + } + .border(2.dp, Purple700, shape = RoundedCornerShape(10)), + verticalArrangement = Arrangement.SpaceEvenly, + horizontalAlignment = Alignment.CenterHorizontally + + ) { + Text( + text = stringResource(R.string.new_), + fontSize = 24.sp, + color = Color.White + ) + Icon( + modifier = Modifier + .size(size.height.toDp() / 2), + imageVector = ImageVector.vectorResource(R.drawable.ic_add), + tint = Color.White, + contentDescription = null + ) + } + } +} + +fun imageFilePickerConfig(initPath: Path? = null) = ArkFilePickerConfig( + mode = ArkFilePickerMode.FILE, + initialPath = initPath, +) + +@Composable +fun Int.toDp() = with(LocalDensity.current) { + this@toDp.toDp() +} + +@Composable +fun Dp.toPx() = with(LocalDensity.current) { + this@toPx.toPx() +} diff --git a/canvas/src/main/java/dev/arkbuilders/canvas/presentation/resourceloader/BitmapResourceManager.kt b/canvas/src/main/java/dev/arkbuilders/canvas/presentation/resourceloader/BitmapResourceManager.kt new file mode 100644 index 0000000..da39e82 --- /dev/null +++ b/canvas/src/main/java/dev/arkbuilders/canvas/presentation/resourceloader/BitmapResourceManager.kt @@ -0,0 +1,158 @@ +package dev.arkbuilders.canvas.presentation.resourceloader + +import android.content.Context +import android.graphics.Bitmap +import android.graphics.Matrix +import android.graphics.drawable.Drawable +import android.media.MediaScannerConnection +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.geometry.Rect +import androidx.compose.ui.graphics.Canvas +import androidx.compose.ui.graphics.ImageBitmap +import androidx.compose.ui.graphics.ImageBitmapConfig +import androidx.compose.ui.graphics.Paint +import androidx.compose.ui.graphics.asAndroidBitmap +import androidx.compose.ui.graphics.asImageBitmap +import androidx.compose.ui.graphics.nativeCanvas +import androidx.compose.ui.unit.toSize +import com.bumptech.glide.Glide +import com.bumptech.glide.RequestBuilder +import com.bumptech.glide.load.engine.DiskCacheStrategy +import com.bumptech.glide.request.target.CustomTarget +import com.bumptech.glide.request.transition.Transition +import dev.arkbuilders.canvas.presentation.drawing.EditManager +import timber.log.Timber +import java.nio.file.Path +import kotlin.io.path.outputStream +import kotlin.system.measureTimeMillis + +class BitmapResourceManager( + val context: Context, + val editManager: EditManager +) : CanvasResourceManager { + + private val glideBuilder = Glide + .with(context) + .asBitmap() + .skipMemoryCache(true) + .diskCacheStrategy(DiskCacheStrategy.NONE) + override suspend fun loadResource(path: Path) { + loadImage(path) + } + + private fun loadImage( + resourcePath: Path, + ) { + glideBuilder + .load(resourcePath.toFile()) + .loadInto() + } + + override suspend fun saveResource(path: Path) { + val combinedBitmap = getEditedImage() + path.outputStream().use { out -> + combinedBitmap.asAndroidBitmap() + .compress(Bitmap.CompressFormat.PNG, 100, out) + } + MediaScannerConnection.scanFile( + context, + arrayOf(path.toString()), + arrayOf("image/*") + ) { _, _ -> } + } + + fun getEditedImage(): ImageBitmap { + val size = editManager.imageSize + var bitmap = ImageBitmap( + size.width, + size.height, + ImageBitmapConfig.Argb8888 + ) + var pathBitmap: ImageBitmap? = null + val time = measureTimeMillis { + editManager.apply { + val matrix = Matrix() + if (editManager.drawPaths.isNotEmpty()) { + pathBitmap = ImageBitmap( + size.width, + size.height, + ImageBitmapConfig.Argb8888 + ) + val pathCanvas = Canvas(pathBitmap!!) + editManager.drawPaths.forEach { + pathCanvas.drawPath(it.path, it.paint) + } + } + backgroundImage.value?.let { + val canvas = Canvas(bitmap) + if (prevRotationAngle == 0f && drawPaths.isEmpty()) { + bitmap = it + return@let + } + if (prevRotationAngle != 0f) { + val centerX = size.width / 2f + val centerY = size.height / 2f + matrix.setRotate(prevRotationAngle, centerX, centerY) + } + canvas.nativeCanvas.drawBitmap( + it.asAndroidBitmap(), + matrix, + null + ) + if (drawPaths.isNotEmpty()) { + canvas.nativeCanvas.drawBitmap( + pathBitmap?.asAndroidBitmap()!!, + matrix, + null + ) + } + } ?: run { + val canvas = Canvas(bitmap) + if (prevRotationAngle != 0f) { + val centerX = size.width / 2 + val centerY = size.height / 2 + matrix.setRotate( + prevRotationAngle, + centerX.toFloat(), + centerY.toFloat() + ) + canvas.nativeCanvas.setMatrix(matrix) + } + canvas.drawRect( + Rect(Offset.Zero, size.toSize()), + backgroundPaint + ) + if (drawPaths.isNotEmpty()) { + canvas.drawImage( + pathBitmap!!, + Offset.Zero, + Paint() + ) + } + } + } + } + Timber.tag("edit-viewmodel: getEditedImage").d( + "processing edits took ${time / 1000} s ${time % 1000} ms" + ) + return bitmap + } + + + private fun RequestBuilder.loadInto() { + into(object : CustomTarget() { + override fun onResourceReady( + bitmap: Bitmap, + transition: Transition? + ) { + editManager.apply { + backgroundImage.value = bitmap.asImageBitmap() + setOriginalBackgroundImage(backgroundImage.value) + scaleToFit() + } + } + + override fun onLoadCleared(placeholder: Drawable?) {} + }) + } +} \ No newline at end of file diff --git a/canvas/src/main/java/dev/arkbuilders/canvas/presentation/resourceloader/CanvasResourceManager.kt b/canvas/src/main/java/dev/arkbuilders/canvas/presentation/resourceloader/CanvasResourceManager.kt new file mode 100644 index 0000000..a015336 --- /dev/null +++ b/canvas/src/main/java/dev/arkbuilders/canvas/presentation/resourceloader/CanvasResourceManager.kt @@ -0,0 +1,8 @@ +package dev.arkbuilders.canvas.presentation.resourceloader + +import java.nio.file.Path + +interface CanvasResourceManager { + suspend fun loadResource(path: Path) + suspend fun saveResource(path: Path) +} \ No newline at end of file diff --git a/canvas/src/main/java/dev/arkbuilders/canvas/presentation/resourceloader/SvgResourceManager.kt b/canvas/src/main/java/dev/arkbuilders/canvas/presentation/resourceloader/SvgResourceManager.kt new file mode 100644 index 0000000..5b11448 --- /dev/null +++ b/canvas/src/main/java/dev/arkbuilders/canvas/presentation/resourceloader/SvgResourceManager.kt @@ -0,0 +1,23 @@ +package dev.arkbuilders.canvas.presentation.resourceloader + +import dev.arkbuilders.canvas.presentation.drawing.EditManager +import dev.arkbuilders.canvas.presentation.utils.SVG +import java.nio.file.Path + +class SvgResourceManager( + private val editManager: EditManager, +): CanvasResourceManager { + + override suspend fun loadResource(path: Path) { + val svgPaths = SVG.parse(path) + svgPaths.getPaths().forEach { draw -> + editManager.addDrawPath(draw.path) + editManager.setPaintColor(draw.paint.color) + } + editManager.svg.addAll(svgPaths.getCommands()) + } + + override suspend fun saveResource(path: Path) { + editManager.svg.generate(path) + } +} \ No newline at end of file diff --git a/canvas/src/main/java/dev/arkbuilders/canvas/presentation/theme/Color.kt b/canvas/src/main/java/dev/arkbuilders/canvas/presentation/theme/Color.kt new file mode 100644 index 0000000..2ea42c9 --- /dev/null +++ b/canvas/src/main/java/dev/arkbuilders/canvas/presentation/theme/Color.kt @@ -0,0 +1,9 @@ +package dev.arkbuilders.canvas.presentation.theme + +import androidx.compose.ui.graphics.Color + +val Purple200 = Color(0xFFBB86FC) +val Purple500 = Color(0xFF6200EE) +val Purple700 = Color(0xFF3700B3) +val Teal200 = Color(0xFF03DAC5) +val Gray = Color(0xFFD3D3D3) diff --git a/canvas/src/main/java/dev/arkbuilders/canvas/presentation/theme/Shape.kt b/canvas/src/main/java/dev/arkbuilders/canvas/presentation/theme/Shape.kt new file mode 100644 index 0000000..42b3876 --- /dev/null +++ b/canvas/src/main/java/dev/arkbuilders/canvas/presentation/theme/Shape.kt @@ -0,0 +1,11 @@ +package dev.arkbuilders.canvas.presentation.theme + +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.Shapes +import androidx.compose.ui.unit.dp + +val Shapes = Shapes( + small = RoundedCornerShape(4.dp), + medium = RoundedCornerShape(4.dp), + large = RoundedCornerShape(0.dp) +) diff --git a/canvas/src/main/java/dev/arkbuilders/canvas/presentation/theme/Theme.kt b/canvas/src/main/java/dev/arkbuilders/canvas/presentation/theme/Theme.kt new file mode 100644 index 0000000..0ea4d42 --- /dev/null +++ b/canvas/src/main/java/dev/arkbuilders/canvas/presentation/theme/Theme.kt @@ -0,0 +1,47 @@ +package dev.arkbuilders.canvas.presentation.theme + +import androidx.compose.foundation.isSystemInDarkTheme +import androidx.compose.material.MaterialTheme +import androidx.compose.material.darkColors +import androidx.compose.material.lightColors +import androidx.compose.runtime.Composable + +private val DarkColorPalette = darkColors( + primary = Purple200, + primaryVariant = Purple700, + secondary = Teal200 +) + +private val LightColorPalette = lightColors( + primary = Purple500, + primaryVariant = Purple700, + secondary = Teal200 + + /* Other default colors to override + background = Color.White, + surface = Color.White, + onPrimary = Color.White, + onSecondary = Color.Black, + onBackground = Color.Black, + onSurface = Color.Black, + */ +) + +@Composable +fun ARKRetouchTheme( + darkTheme: Boolean = isSystemInDarkTheme(), + content: @Composable () -> Unit +) { + val colors = if (darkTheme) { + DarkColorPalette + } else { + LightColorPalette + } + + MaterialTheme( + colors = colors, + typography = Typography, + shapes = Shapes, + content = content + ) +} diff --git a/canvas/src/main/java/dev/arkbuilders/canvas/presentation/theme/Type.kt b/canvas/src/main/java/dev/arkbuilders/canvas/presentation/theme/Type.kt new file mode 100644 index 0000000..e7056de --- /dev/null +++ b/canvas/src/main/java/dev/arkbuilders/canvas/presentation/theme/Type.kt @@ -0,0 +1,28 @@ +package dev.arkbuilders.canvas.presentation.theme + +import androidx.compose.material.Typography +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.sp + +// Set of Material typography styles to start with +val Typography = Typography( + body1 = TextStyle( + fontFamily = FontFamily.Default, + fontWeight = FontWeight.Normal, + fontSize = 16.sp + ) + /* Other default text styles to override + button = TextStyle( + fontFamily = FontFamily.Default, + fontWeight = FontWeight.W500, + fontSize = 14.sp + ), + caption = TextStyle( + fontFamily = FontFamily.Default, + fontWeight = FontWeight.Normal, + fontSize = 12.sp + ) + */ +) diff --git a/canvas/src/main/java/dev/arkbuilders/canvas/presentation/utils/ImageHelper.kt b/canvas/src/main/java/dev/arkbuilders/canvas/presentation/utils/ImageHelper.kt new file mode 100644 index 0000000..cdeaca5 --- /dev/null +++ b/canvas/src/main/java/dev/arkbuilders/canvas/presentation/utils/ImageHelper.kt @@ -0,0 +1,33 @@ +package dev.arkbuilders.canvas.presentation.utils + +import android.graphics.Bitmap +import android.graphics.Matrix +import dev.arkbuilders.canvas.presentation.edit.crop.CropWindow +import dev.arkbuilders.canvas.presentation.edit.resize.ResizeOperation +import dev.arkbuilders.canvas.presentation.edit.rotate.RotateOperation + +fun Bitmap.crop(cropParams: CropWindow.CropParams): Bitmap = Bitmap.createBitmap( + this, + cropParams.x, + cropParams.y, + cropParams.width, + cropParams.height +) + +fun Bitmap.resize(scale: ResizeOperation.Scale): Bitmap { + val matrix = Matrix() + matrix.postScale(scale.x, scale.y) + return Bitmap.createBitmap( + this, + 0, + 0, + width, + height, + matrix, + true + ) +} + +fun Matrix.rotate(angle: Float, center: RotateOperation.Center) { + this.postRotate(angle, center.x, center.y) +} diff --git a/canvas/src/main/java/dev/arkbuilders/canvas/presentation/utils/PermissionsHelper.kt b/canvas/src/main/java/dev/arkbuilders/canvas/presentation/utils/PermissionsHelper.kt new file mode 100644 index 0000000..5fe2ff8 --- /dev/null +++ b/canvas/src/main/java/dev/arkbuilders/canvas/presentation/utils/PermissionsHelper.kt @@ -0,0 +1,45 @@ +package dev.arkbuilders.canvas.presentation.utils + +import android.Manifest +import android.annotation.TargetApi +import android.content.Context +import android.content.Intent +import android.net.Uri +import android.os.Build +import android.os.Environment +import android.provider.Settings +import androidx.activity.result.ActivityResultLauncher +import androidx.activity.result.contract.ActivityResultContract +import androidx.activity.result.contract.ActivityResultContracts + +object PermissionsHelper { + fun writePermContract(): ActivityResultContract { + return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { + AllFilesAccessContract() + } else { + ActivityResultContracts.RequestPermission() + } + } + + fun launchWritePerm(launcher: ActivityResultLauncher) { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { + val packageUri = "package:" +" BuildConfig.APPLICATION_ID" + launcher.launch(packageUri) + } else { + launcher.launch(Manifest.permission.WRITE_EXTERNAL_STORAGE) + } + } +} + +@TargetApi(30) +private class AllFilesAccessContract : ActivityResultContract() { + override fun createIntent(context: Context, input: String): Intent { + return Intent( + Settings.ACTION_MANAGE_APP_ALL_FILES_ACCESS_PERMISSION, + Uri.parse(input) + ) + } + + override fun parseResult(resultCode: Int, intent: Intent?) = + Environment.isExternalStorageManager() +} diff --git a/canvas/src/main/java/dev/arkbuilders/canvas/presentation/utils/SVG.kt b/canvas/src/main/java/dev/arkbuilders/canvas/presentation/utils/SVG.kt new file mode 100644 index 0000000..2d87d24 --- /dev/null +++ b/canvas/src/main/java/dev/arkbuilders/canvas/presentation/utils/SVG.kt @@ -0,0 +1,326 @@ +package dev.arkbuilders.canvas.presentation.utils + +import android.util.Log +import android.util.Xml +import androidx.compose.ui.graphics.Color +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.Path as ComposePath +import dev.arkbuilders.canvas.presentation.drawing.DrawPath +import org.xmlpull.v1.XmlPullParser +import org.xmlpull.v1.XmlSerializer +import java.nio.file.Path +import kotlin.io.path.reader +import kotlin.io.path.writer + + +class SVG { + private var strokeColor: ULong = Color.Black.value + private var strokeSize: Float = SVGCommand.DEFAULT_BRUSH_SIZE + private var fill = "none" + private var viewBox = ViewBox() + private val commands = ArrayDeque() + private val paths = ArrayDeque() + + private val paint + get() = Paint().also { + it.style = PaintingStyle.Stroke + it.strokeWidth = strokeSize + it.strokeCap = StrokeCap.Round + it.strokeJoin = StrokeJoin.Round + it.isAntiAlias = true + } + + fun addCommand(command: SVGCommand) { + commands.addLast(command) + } + + fun getCommands(): ArrayDeque { + return commands + } + + fun addPath(path: DrawPath) { + paths.addLast(path) + } + + fun generate(path: Path): XmlSerializer? { + if (commands.isNotEmpty()) { + val xmlSerializer = Xml.newSerializer() + val pathData = commands.joinToString() + xmlSerializer.apply { + setOutput(path.writer()) + startDocument("utf-8", false) + startTag("", SVG_TAG) + attribute("", Attributes.VIEW_BOX, viewBox.toString()) + attribute("", Attributes.XML_NS_URI, XML_NS_URI) + startTag("", PATH_TAG) + attribute("", Attributes.Path.STROKE, strokeColor.toString()) + attribute("", Attributes.Path.FILL, fill) + attribute("", Attributes.Path.DATA, pathData) + endTag("", PATH_TAG) + endTag("", SVG_TAG) + endDocument() + } + return xmlSerializer + } + return null + } + + fun getPaths(): Collection = paths + + fun copy(): SVG = SVG().apply { + strokeColor = this@SVG.strokeColor + fill = this@SVG.fill + viewBox = this@SVG.viewBox + commands.addAll(this@SVG.commands) + paths.addAll(this@SVG.paths) + } + + private fun createCanvasPaths() { + if (commands.isNotEmpty()) { + if (paths.isNotEmpty()) paths.clear() + var path = ComposePath() + commands.forEach { command -> + strokeColor = command.paintColor + strokeSize = command.brushSize + when (command) { + is SVGCommand.MoveTo -> { + path = ComposePath() + path.moveTo(command.x, command.y) + } + + is SVGCommand.AbsQuadTo -> { + path.quadraticTo(command.x1, command.y1, command.x2, command.y2) + } + + is SVGCommand.AbsLineTo -> { + path.lineTo(command.x, command.y) + } + } + + paths.addLast( + DrawPath( + path = path, + paint = paint.apply { + color = Color(command.paintColor) + strokeWidth = 3f + } + ) + ) + } + } + } + + fun addAll(commands: ArrayDeque) { + commands.addAll(commands) + } + + companion object { + fun parse(path: Path): SVG = SVG().apply { + val xmlParser = Xml.newPullParser() + var pathData = "" + + xmlParser.apply { + setInput(path.reader()) + + var event = xmlParser.eventType + var pathCount = 0 + while (event != XmlPullParser.END_DOCUMENT) { + val tag = xmlParser.name + when (event) { + XmlPullParser.START_TAG -> { + when (tag) { + SVG_TAG -> { + viewBox = ViewBox.fromString( + getAttributeValue("", Attributes.VIEW_BOX) + ) + } + + PATH_TAG -> { + pathCount += 1 + strokeColor = + getAttributeValue("", Attributes.Path.STROKE).toULong() + fill = getAttributeValue("", Attributes.Path.FILL) + pathData = getAttributeValue("", Attributes.Path.DATA) + } + } + if (pathCount > 1) { + Log.d("svg", "found more than 1 path in file") + break + } + } + } + + event = next() + } + fun extractStrokeFromCommand(originalCommand: String, commandElements: List) { + if (commandElements.size > 3) { + strokeColor = commandElements[3].toULong() + } + if (commandElements.size > 4) { + strokeSize = commandElements[4].toFloat() + } + commands.addLast(SVGCommand.MoveTo.fromString(originalCommand).apply { + paintColor = strokeColor + brushSize = strokeSize + }) + } + pathData.split(COMMA).forEach { + val command = it.trim() + if (command.isEmpty()) return@forEach + val commandElements = command.split(" ") + + when (command.first()) { + SVGCommand.MoveTo.CODE -> { + extractStrokeFromCommand(originalCommand = command, commandElements = commandElements) + } + SVGCommand.AbsLineTo.CODE -> { + extractStrokeFromCommand(originalCommand = command, commandElements = commandElements) + } + SVGCommand.AbsQuadTo.CODE -> { + if (commandElements.size > 5) { + strokeColor = commandElements[5].toULong() + } + if (commandElements.size > 6) { + strokeSize = commandElements[6].toFloat() + } + commands.addLast(SVGCommand.AbsQuadTo.fromString(command).apply { + paintColor = strokeColor + brushSize = strokeSize + }) + } + + else -> {} + } + } + + createCanvasPaths() + } + } + + private object Attributes { + const val VIEW_BOX = "viewBox" + const val XML_NS_URI = "xmlns" + + object Path { + const val STROKE = "stroke" + const val FILL = "fill" + const val DATA = "d" + } + } + } +} + +data class ViewBox( + val x: Float = 0f, + val y: Float = 0f, + val width: Float = 100f, + val height: Float = 100f +) { + override fun toString(): String = "$x $y $width $height" + + companion object { + fun fromString(string: String): ViewBox { + val viewBox = string.split(" ") + return ViewBox( + viewBox[0].toFloat(), + viewBox[1].toFloat(), + viewBox[2].toFloat(), + viewBox[3].toFloat() + ) + } + } +} + +sealed class SVGCommand { + + companion object { + const val DEFAULT_BRUSH_SIZE = 3.0f + } + + var paintColor: ULong = Color.Black.value + var brushSize: Float = 3.0f + + class MoveTo( + val x: Float, + val y: Float + ) : SVGCommand() { + override fun toString(): String = "$CODE $x $y $paintColor $brushSize" + + companion object { + const val CODE = 'M' + + fun fromString(string: String): SVGCommand { + val params = string.removePrefix("$CODE").trim().split(" ") + val x = params[0].toFloat() + val y = params[1].toFloat() + val colorCode = if (params.size > 2) params[2].toULong() else Color.Black.value + val strokeSize = + if (params.size > 3) params[3].toFloat() else DEFAULT_BRUSH_SIZE + return MoveTo(x, y).apply { + paintColor = colorCode + brushSize = strokeSize + } + } + } + } + + class AbsLineTo( + val x: Float, + val y: Float + ) : SVGCommand() { + override fun toString(): String = "$CODE $x $y $paintColor $brushSize" + + companion object { + const val CODE = 'L' + + fun fromString(string: String): SVGCommand { + val params = string.removePrefix("$CODE").trim().split(" ") + val x = params[0].toFloat() + val y = params[1].toFloat() + val colorCode = if (params.size > 2) params[2].toULong() else Color.Black.value + val strokeSize = + if (params.size > 3) params[3].toFloat() else DEFAULT_BRUSH_SIZE + return AbsLineTo(x, y).apply { + paintColor = colorCode + brushSize = strokeSize + } + } + } + } + + class AbsQuadTo( + val x1: Float, + val y1: Float, + val x2: Float, + val y2: Float + ) : SVGCommand() { + override fun toString(): String = "$CODE $x1 $y1 $x2 $y2 $paintColor $brushSize" + + companion object { + const val CODE = 'Q' + + fun fromString(string: String): SVGCommand { + val params = string.removePrefix("$CODE").trim().split(" ") + val x1 = params[0].toFloat() + val y1 = params[1].toFloat() + val x2 = params[2].toFloat() + val y2 = params[3].toFloat() + val colorCode = if (params.size > 4) params[4].toULong() else Color.Black.value + val strokeSize = + if (params.size > 5) params[5].toFloat() else DEFAULT_BRUSH_SIZE + return AbsQuadTo(x1, y1, x2, y2).apply { + paintColor = colorCode + brushSize = strokeSize + } + } + } + } +} + +private const val COMMA = "," +private const val XML_NS_URI = "http://www.w3.org/2000/svg" +private const val SVG_TAG = "svg" +private const val PATH_TAG = "path" \ No newline at end of file diff --git a/canvas/src/main/java/dev/arkbuilders/canvas/presentation/utils/Utils.kt b/canvas/src/main/java/dev/arkbuilders/canvas/presentation/utils/Utils.kt new file mode 100644 index 0000000..c183b41 --- /dev/null +++ b/canvas/src/main/java/dev/arkbuilders/canvas/presentation/utils/Utils.kt @@ -0,0 +1,105 @@ +package dev.arkbuilders.canvas.presentation.utils + +import android.Manifest +import android.content.Context +import android.content.ContextWrapper +import android.content.Intent +import android.content.pm.PackageManager +import android.net.Uri +import android.os.Build +import android.os.Environment +import android.provider.Settings +import android.widget.Toast +import androidx.annotation.StringRes +import androidx.appcompat.app.AppCompatActivity +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.input.pointer.PointerEvent +import androidx.core.app.ActivityCompat +import androidx.core.content.ContextCompat +import java.nio.file.Path +import kotlin.io.path.exists +import kotlin.io.path.extension +import kotlin.io.path.nameWithoutExtension +import kotlin.math.atan2 + +fun Path.findNotExistCopyName(name: Path): Path { + val parent = this + var filesCounter = 1 + + fun formatNameWithCounter() = + "${name.nameWithoutExtension}_$filesCounter.${name.extension}" + + var newPath = parent.resolve(formatNameWithCounter()) + + while (newPath.exists()) { + newPath = parent.resolve(formatNameWithCounter()) + filesCounter++ + } + return newPath +} + +fun Context.askWritePermissions() = getActivity()?.apply { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { + val packageUri = + Uri.parse("package:") + val intent = + Intent( + Settings.ACTION_MANAGE_APP_ALL_FILES_ACCESS_PERMISSION, + packageUri + ) + startActivityForResult(intent, 1) + } else { + ActivityCompat.requestPermissions( + this, + arrayOf(Manifest.permission.WRITE_EXTERNAL_STORAGE), + 2 + ) + } +} + +fun Context.isWritePermGranted(): Boolean { + return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { + Environment.isExternalStorageManager() + } else { + ContextCompat.checkSelfPermission( + this, + Manifest.permission.WRITE_EXTERNAL_STORAGE + ) == PackageManager.PERMISSION_GRANTED + } +} + +fun Context.getActivity(): AppCompatActivity? = when (this) { + is AppCompatActivity -> this + is ContextWrapper -> baseContext.getActivity() + else -> null +} + +typealias Degrees = Float + +fun PointerEvent.calculateRotationFromOneFingerGesture( + center: Offset +): Degrees { + var angleDelta = 0.0 + changes.forEach { change -> + if (change.pressed) { + val currentPosition = change.position + val prevPosition = change.previousPosition + val prevOffset = prevPosition - center + val currentOffset = currentPosition - center + val prevAngle = atan2( + prevOffset.y.toDouble(), + prevOffset.x.toDouble() + ) + val currentAngle = atan2( + currentOffset.y.toDouble(), + currentOffset.x.toDouble() + ) + angleDelta = Math.toDegrees(currentAngle - prevAngle) + } + } + return angleDelta.toFloat() +} + +fun Context.toast(@StringRes stringId: Int) { + Toast.makeText(this, getString(stringId), Toast.LENGTH_SHORT).show() +} diff --git a/canvas/src/main/java/dev/arkbuilders/canvas/presentation/utils/adapters/BrushAttribute.kt b/canvas/src/main/java/dev/arkbuilders/canvas/presentation/utils/adapters/BrushAttribute.kt new file mode 100644 index 0000000..12bfa22 --- /dev/null +++ b/canvas/src/main/java/dev/arkbuilders/canvas/presentation/utils/adapters/BrushAttribute.kt @@ -0,0 +1,27 @@ +package dev.arkbuilders.canvas.presentation.utils.adapters + +sealed interface BrushAttribute { + var isSelected: Boolean +} + +sealed class BrushSize : BrushAttribute { + override var isSelected = false +} + +sealed class BrushColor : BrushAttribute { + override var isSelected = false +} + +data object BrushSizeTiny : BrushSize() +data object BrushSizeSmall : BrushSize() +data object BrushSizeMedium : BrushSize() +data object BrushSizeLarge : BrushSize() +data object BrushSizeHuge : BrushSize() + +data object BrushColorBlack : BrushColor() +data object BrushColorGrey : BrushColor() +data object BrushColorRed : BrushColor() +data object BrushColorOrange : BrushColor() +data object BrushColorGreen : BrushColor() +data object BrushColorBlue : BrushColor() +data object BrushColorPurple : BrushColor() \ No newline at end of file diff --git a/canvas/src/main/res/drawable/ic_add.xml b/canvas/src/main/res/drawable/ic_add.xml new file mode 100644 index 0000000..e40b9ce --- /dev/null +++ b/canvas/src/main/res/drawable/ic_add.xml @@ -0,0 +1,13 @@ + + + diff --git a/canvas/src/main/res/drawable/ic_arrow_back.xml b/canvas/src/main/res/drawable/ic_arrow_back.xml new file mode 100644 index 0000000..6bd5650 --- /dev/null +++ b/canvas/src/main/res/drawable/ic_arrow_back.xml @@ -0,0 +1,5 @@ + + + diff --git a/canvas/src/main/res/drawable/ic_aspect_ratio.xml b/canvas/src/main/res/drawable/ic_aspect_ratio.xml new file mode 100644 index 0000000..77f9080 --- /dev/null +++ b/canvas/src/main/res/drawable/ic_aspect_ratio.xml @@ -0,0 +1,5 @@ + + + diff --git a/canvas/src/main/res/drawable/ic_blur_on.xml b/canvas/src/main/res/drawable/ic_blur_on.xml new file mode 100644 index 0000000..92390c9 --- /dev/null +++ b/canvas/src/main/res/drawable/ic_blur_on.xml @@ -0,0 +1,5 @@ + + + diff --git a/canvas/src/main/res/drawable/ic_check.xml b/canvas/src/main/res/drawable/ic_check.xml new file mode 100644 index 0000000..cf143d4 --- /dev/null +++ b/canvas/src/main/res/drawable/ic_check.xml @@ -0,0 +1,5 @@ + + + diff --git a/canvas/src/main/res/drawable/ic_clear.xml b/canvas/src/main/res/drawable/ic_clear.xml new file mode 100644 index 0000000..844b6b6 --- /dev/null +++ b/canvas/src/main/res/drawable/ic_clear.xml @@ -0,0 +1,5 @@ + + + diff --git a/canvas/src/main/res/drawable/ic_crop.xml b/canvas/src/main/res/drawable/ic_crop.xml new file mode 100644 index 0000000..8e91f26 --- /dev/null +++ b/canvas/src/main/res/drawable/ic_crop.xml @@ -0,0 +1,5 @@ + + + diff --git a/canvas/src/main/res/drawable/ic_crop_16_9.xml b/canvas/src/main/res/drawable/ic_crop_16_9.xml new file mode 100644 index 0000000..d7bdc55 --- /dev/null +++ b/canvas/src/main/res/drawable/ic_crop_16_9.xml @@ -0,0 +1,5 @@ + + + diff --git a/canvas/src/main/res/drawable/ic_crop_3_2.xml b/canvas/src/main/res/drawable/ic_crop_3_2.xml new file mode 100644 index 0000000..466797f --- /dev/null +++ b/canvas/src/main/res/drawable/ic_crop_3_2.xml @@ -0,0 +1,5 @@ + + + diff --git a/canvas/src/main/res/drawable/ic_crop_5_4.xml b/canvas/src/main/res/drawable/ic_crop_5_4.xml new file mode 100644 index 0000000..8993425 --- /dev/null +++ b/canvas/src/main/res/drawable/ic_crop_5_4.xml @@ -0,0 +1,5 @@ + + + diff --git a/canvas/src/main/res/drawable/ic_crop_square.xml b/canvas/src/main/res/drawable/ic_crop_square.xml new file mode 100644 index 0000000..7d6b01b --- /dev/null +++ b/canvas/src/main/res/drawable/ic_crop_square.xml @@ -0,0 +1,5 @@ + + + diff --git a/canvas/src/main/res/drawable/ic_eraser.xml b/canvas/src/main/res/drawable/ic_eraser.xml new file mode 100644 index 0000000..ab1f850 --- /dev/null +++ b/canvas/src/main/res/drawable/ic_eraser.xml @@ -0,0 +1,13 @@ + + + diff --git a/canvas/src/main/res/drawable/ic_eyedropper.xml b/canvas/src/main/res/drawable/ic_eyedropper.xml new file mode 100644 index 0000000..e897caa --- /dev/null +++ b/canvas/src/main/res/drawable/ic_eyedropper.xml @@ -0,0 +1,4 @@ + + + diff --git a/canvas/src/main/res/drawable/ic_insert_photo.xml b/canvas/src/main/res/drawable/ic_insert_photo.xml new file mode 100644 index 0000000..35960a0 --- /dev/null +++ b/canvas/src/main/res/drawable/ic_insert_photo.xml @@ -0,0 +1,5 @@ + + + diff --git a/canvas/src/main/res/drawable/ic_line_weight.xml b/canvas/src/main/res/drawable/ic_line_weight.xml new file mode 100644 index 0000000..11f94cd --- /dev/null +++ b/canvas/src/main/res/drawable/ic_line_weight.xml @@ -0,0 +1,5 @@ + + + diff --git a/canvas/src/main/res/drawable/ic_more_vert.xml b/canvas/src/main/res/drawable/ic_more_vert.xml new file mode 100644 index 0000000..39fbab5 --- /dev/null +++ b/canvas/src/main/res/drawable/ic_more_vert.xml @@ -0,0 +1,5 @@ + + + diff --git a/canvas/src/main/res/drawable/ic_pan_tool.xml b/canvas/src/main/res/drawable/ic_pan_tool.xml new file mode 100644 index 0000000..5a9158f --- /dev/null +++ b/canvas/src/main/res/drawable/ic_pan_tool.xml @@ -0,0 +1,5 @@ + + + diff --git a/canvas/src/main/res/drawable/ic_redo.xml b/canvas/src/main/res/drawable/ic_redo.xml new file mode 100644 index 0000000..b486fdb --- /dev/null +++ b/canvas/src/main/res/drawable/ic_redo.xml @@ -0,0 +1,5 @@ + + + diff --git a/canvas/src/main/res/drawable/ic_rotate_90_degrees_ccw.xml b/canvas/src/main/res/drawable/ic_rotate_90_degrees_ccw.xml new file mode 100644 index 0000000..255da67 --- /dev/null +++ b/canvas/src/main/res/drawable/ic_rotate_90_degrees_ccw.xml @@ -0,0 +1,5 @@ + + + diff --git a/canvas/src/main/res/drawable/ic_rotate_left.xml b/canvas/src/main/res/drawable/ic_rotate_left.xml new file mode 100644 index 0000000..b644196 --- /dev/null +++ b/canvas/src/main/res/drawable/ic_rotate_left.xml @@ -0,0 +1,5 @@ + + + diff --git a/canvas/src/main/res/drawable/ic_rotate_right.xml b/canvas/src/main/res/drawable/ic_rotate_right.xml new file mode 100644 index 0000000..9fa1482 --- /dev/null +++ b/canvas/src/main/res/drawable/ic_rotate_right.xml @@ -0,0 +1,5 @@ + + + diff --git a/canvas/src/main/res/drawable/ic_save.xml b/canvas/src/main/res/drawable/ic_save.xml new file mode 100644 index 0000000..dbc4c4e --- /dev/null +++ b/canvas/src/main/res/drawable/ic_save.xml @@ -0,0 +1,5 @@ + + + diff --git a/canvas/src/main/res/drawable/ic_share.xml b/canvas/src/main/res/drawable/ic_share.xml new file mode 100644 index 0000000..87cea78 --- /dev/null +++ b/canvas/src/main/res/drawable/ic_share.xml @@ -0,0 +1,5 @@ + + + diff --git a/canvas/src/main/res/drawable/ic_undo.xml b/canvas/src/main/res/drawable/ic_undo.xml new file mode 100644 index 0000000..5fd6184 --- /dev/null +++ b/canvas/src/main/res/drawable/ic_undo.xml @@ -0,0 +1,5 @@ + + + diff --git a/canvas/src/main/res/drawable/ic_zoom_in.xml b/canvas/src/main/res/drawable/ic_zoom_in.xml new file mode 100644 index 0000000..670484f --- /dev/null +++ b/canvas/src/main/res/drawable/ic_zoom_in.xml @@ -0,0 +1,6 @@ + + + + diff --git a/canvas/src/main/res/layout/fragment_ark_canvas.xml b/canvas/src/main/res/layout/fragment_ark_canvas.xml new file mode 100644 index 0000000..5d694b3 --- /dev/null +++ b/canvas/src/main/res/layout/fragment_ark_canvas.xml @@ -0,0 +1,12 @@ + + + + + + \ No newline at end of file diff --git a/canvas/src/main/res/values/strings.xml b/canvas/src/main/res/values/strings.xml new file mode 100644 index 0000000..2b47406 --- /dev/null +++ b/canvas/src/main/res/values/strings.xml @@ -0,0 +1,42 @@ + + + Hello blank fragment + Intensity + Size + Free\n + + + Name + Overwrite original file + Cancel + OK + Location + Pick folder + Open + Or + New + Crop + Square + 9:16 + 2:3 + 4:5 + Options + Share + Clear + Height + Width + Input digits only + Width should be less than %d + Height should be less than %d + Sorry, the application crashed. Please send a report to the developers. + Pick a color please + Background + Height cannot be %s + Width cannot be %s + Please enter width + Please enter height + Please pick folder to save image + Please provide file name + Save + + \ No newline at end of file diff --git a/canvas/src/test/java/dev/arkbuilders/canvas/ExampleUnitTest.kt b/canvas/src/test/java/dev/arkbuilders/canvas/ExampleUnitTest.kt new file mode 100644 index 0000000..5f8a8e9 --- /dev/null +++ b/canvas/src/test/java/dev/arkbuilders/canvas/ExampleUnitTest.kt @@ -0,0 +1,17 @@ +package dev.arkbuilders.canvas + +import org.junit.Test + +import org.junit.Assert.* + +/** + * Example local unit test, which will execute on the development machine (host). + * + * See [testing documentation](http://d.android.com/tools/testing). + */ +class ExampleUnitTest { + @Test + fun addition_isCorrect() { + assertEquals(4, 2 + 2) + } +} \ No newline at end of file diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 19dcc50..8e75449 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -17,6 +17,11 @@ flexbox = "3.0.0" composeActivity = "1.9.0" composeBom = "2024.06.00" composeCompiler = "1.5.10" +activity = "1.8.0" +kotlin = "1.9.22" +constraintlayout = "2.1.4" +uiAndroid = "1.7.5" +agp = "8.2.2" [libraries] coil = { group = "io.coil-kt", name = "coil", version.ref = "coil" } @@ -47,3 +52,9 @@ androidx-compose-ui-graphics = { group = "androidx.compose.ui", name = "ui-graph androidx-compose-ui-tooling = { group = "androidx.compose.ui", name = "ui-tooling" } androidx-compose-ui-tooling-preview = { group = "androidx.compose.ui", name = "ui-tooling-preview" } androidx-compose-material3 = { group = "androidx.compose.material3", name = "material3" } +androidx-activity = { group = "androidx.activity", name = "activity", version.ref = "activity" } +androidx-constraintlayout = { group = "androidx.constraintlayout", name = "constraintlayout", version.ref = "constraintlayout" } +androidx-ui-android = { group = "androidx.compose.ui", name = "ui-android", version.ref = "uiAndroid" } +[plugins] +jetbrainsKotlinAndroid = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" } +androidLibrary = { id = "com.android.library", version.ref = "agp" } \ No newline at end of file diff --git a/sample/build.gradle.kts b/sample/build.gradle.kts index 5897047..e9f85e2 100644 --- a/sample/build.gradle.kts +++ b/sample/build.gradle.kts @@ -9,7 +9,7 @@ android { defaultConfig { applicationId = "dev.arkbuilders.sample" - minSdk = 26 + minSdk = 29 targetSdk = 34 versionCode = 1 versionName = "1.0" @@ -46,6 +46,11 @@ android { buildFeatures { buildConfig = true viewBinding = true + compose = true + } + + composeOptions { + kotlinCompilerExtensionVersion = "1.5.10" } splits { @@ -72,11 +77,13 @@ android { dependencies { implementation(project(":filepicker")) implementation(project(":about")) + implementation(project(":canvas")) implementation(libraries.arklib) implementation("androidx.core:core-ktx:1.12.0") implementation(libraries.androidx.appcompat) implementation(libraries.android.material) + implementation(libs.androidx.ui.android) testImplementation(libraries.junit) androidTestImplementation(libraries.androidx.test.junit) androidTestImplementation(libraries.androidx.test.espresso) diff --git a/sample/src/main/AndroidManifest.xml b/sample/src/main/AndroidManifest.xml index 4680920..d7fc38f 100644 --- a/sample/src/main/AndroidManifest.xml +++ b/sample/src/main/AndroidManifest.xml @@ -6,7 +6,6 @@ android:name="android.permission.WRITE_EXTERNAL_STORAGE" android:maxSdkVersion="32" tools:ignore="ScopedStorage" /> - @@ -21,8 +20,10 @@ android:roundIcon="@mipmap/ic_launcher_round" android:supportsRtl="true" android:theme="@style/Theme.ArkComponents" - tools:targetApi="31" > - + tools:targetApi="31"> + - - + \ No newline at end of file diff --git a/sample/src/main/java/dev/arkbuilders/sample/MainActivity.kt b/sample/src/main/java/dev/arkbuilders/sample/MainActivity.kt index db201bd..a7f2ae5 100644 --- a/sample/src/main/java/dev/arkbuilders/sample/MainActivity.kt +++ b/sample/src/main/java/dev/arkbuilders/sample/MainActivity.kt @@ -18,6 +18,7 @@ import dev.arkbuilders.components.filepicker.ArkFilePickerFragment import dev.arkbuilders.components.filepicker.ArkFilePickerMode import dev.arkbuilders.components.filepicker.onArkPathPicked import dev.arkbuilders.sample.about.AboutActivity +import dev.arkbuilders.sample.canvas.CanvasActivity import dev.arkbuilders.sample.storage.StorageDemoFragment class MainActivity : AppCompatActivity() { @@ -54,11 +55,14 @@ class MainActivity : AppCompatActivity() { findViewById(R.id.btn_storage_demo).setOnClickListener { StorageDemoFragment().show(supportFragmentManager, StorageDemoFragment::class.java.name) } - findViewById(R.id.btn_about).setOnClickListener { val intent = Intent(this, AboutActivity::class.java) startActivity(intent) } + findViewById(R.id.btn_canvas).setOnClickListener { + val intent = Intent(this, CanvasActivity::class.java) + startActivity(intent) + } } private fun getFilePickerConfig(mode: ArkFilePickerMode? = null) = ArkFilePickerConfig( diff --git a/sample/src/main/java/dev/arkbuilders/sample/canvas/CanvasActivity.kt b/sample/src/main/java/dev/arkbuilders/sample/canvas/CanvasActivity.kt new file mode 100644 index 0000000..bc767c3 --- /dev/null +++ b/sample/src/main/java/dev/arkbuilders/sample/canvas/CanvasActivity.kt @@ -0,0 +1,18 @@ +package dev.arkbuilders.sample.canvas + +import android.os.Bundle +import androidx.appcompat.app.AppCompatActivity +import dev.arkbuilders.sample.R + +class CanvasActivity : AppCompatActivity() { + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + setContentView(R.layout.activity_canvas) + val filePickerFragment = FilePickerFragment.newInstance() + + supportFragmentManager + .beginTransaction() + .replace(R.id.canvas_content, filePickerFragment) + .commit() + } +} \ No newline at end of file diff --git a/sample/src/main/java/dev/arkbuilders/sample/canvas/FilePickerFragment.kt b/sample/src/main/java/dev/arkbuilders/sample/canvas/FilePickerFragment.kt new file mode 100644 index 0000000..ba3ed12 --- /dev/null +++ b/sample/src/main/java/dev/arkbuilders/sample/canvas/FilePickerFragment.kt @@ -0,0 +1,59 @@ +package dev.arkbuilders.sample.canvas + +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import androidx.compose.ui.platform.ComposeView +import androidx.compose.ui.platform.ViewCompositionStrategy +import androidx.fragment.app.Fragment +import androidx.fragment.app.FragmentManager +import dev.arkbuilders.canvas.presentation.ArkCanvasFragment +import dev.arkbuilders.canvas.presentation.picker.PickerScreen +import dev.arkbuilders.sample.R +import java.nio.file.Path + +class FilePickerFragment : Fragment() { + override fun onCreateView( + inflater: LayoutInflater, container: ViewGroup?, + savedInstanceState: Bundle? + ): View? { + return inflater.inflate(R.layout.fragment_file_picker, container, false) + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + + val composeView = view.findViewById(dev.arkbuilders.canvas.R.id.compose_view) + + composeView.apply { + setViewCompositionStrategy( + ViewCompositionStrategy.DisposeOnViewTreeLifecycleDestroyed + ) + setContent { + // Set Content here + PickerScreen(fragmentManager = requireActivity().supportFragmentManager, + onNavigateToEdit = { path, resolution -> + onNavigateToEdit(path, requireActivity().supportFragmentManager) + }) + } + } + } + + private fun onNavigateToEdit(path: Path?, fragmentManager: FragmentManager) { + val canvasFragment = ArkCanvasFragment.newInstance( + param1 = path?.toString() ?: "" + ) + + fragmentManager + .beginTransaction() + .add(R.id.canvas_content, canvasFragment) + .addToBackStack(null) + .commit() + } + + companion object { + @JvmStatic + fun newInstance() = FilePickerFragment() + } +} \ No newline at end of file diff --git a/sample/src/main/res/layout/activity_canvas.xml b/sample/src/main/res/layout/activity_canvas.xml new file mode 100644 index 0000000..a79f577 --- /dev/null +++ b/sample/src/main/res/layout/activity_canvas.xml @@ -0,0 +1,11 @@ + + + + + + \ No newline at end of file diff --git a/sample/src/main/res/layout/activity_main.xml b/sample/src/main/res/layout/activity_main.xml index 09d74b6..b3864a7 100644 --- a/sample/src/main/res/layout/activity_main.xml +++ b/sample/src/main/res/layout/activity_main.xml @@ -50,4 +50,13 @@ app:layout_constraintEnd_toEndOf="parent" app:layout_constraintTop_toBottomOf="@+id/btn_storage_demo"/> + + \ No newline at end of file diff --git a/sample/src/main/res/layout/fragment_canvas.xml b/sample/src/main/res/layout/fragment_canvas.xml new file mode 100644 index 0000000..5d694b3 --- /dev/null +++ b/sample/src/main/res/layout/fragment_canvas.xml @@ -0,0 +1,12 @@ + + + + + + \ No newline at end of file diff --git a/sample/src/main/res/layout/fragment_file_picker.xml b/sample/src/main/res/layout/fragment_file_picker.xml new file mode 100644 index 0000000..5d694b3 --- /dev/null +++ b/sample/src/main/res/layout/fragment_file_picker.xml @@ -0,0 +1,12 @@ + + + + + + \ No newline at end of file diff --git a/sample/src/main/res/values/strings.xml b/sample/src/main/res/values/strings.xml index 0701830..9edc949 100644 --- a/sample/src/main/res/values/strings.xml +++ b/sample/src/main/res/values/strings.xml @@ -9,4 +9,7 @@ Delete map entry Empty Open About + Open Canvas + + Hello blank fragment \ No newline at end of file diff --git a/settings.gradle.kts b/settings.gradle.kts index b7b6b93..0dceab7 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -1,5 +1,8 @@ import java.net.URI +include(":canvas") + + pluginManagement { repositories { google()