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()