From fdd85b5546cfde0eb96936831dd23ffb4bc2becd Mon Sep 17 00:00:00 2001 From: Hieu Vu Date: Wed, 2 Oct 2024 22:07:21 +0700 Subject: [PATCH 1/5] Move canvas module from retouch to memo, integrate compose --- app/build.gradle | 22 + .../main/java/dev/arkbuilders/arkmemo/App.kt | 2 + .../arkbuilders/arkmemo/di/AppComponent.kt | 22 + .../dev/arkbuilders/arkmemo/di/DIManager.kt | 12 + .../ui/fragments/BaseEditNoteFragment.kt | 14 +- .../ui/fragments/EditGraphicNotesFragment.kt | 255 +--- .../arkmemo/ui/views/data/ImageDefaults.kt | 23 + .../arkmemo/ui/views/data/Preferences.kt | 92 ++ .../views/presentation/drawing/EditCanvas.kt | 335 ++++++ .../views/presentation/drawing/EditManager.kt | 621 ++++++++++ .../presentation/edit/ColorPickerDialog.kt | 254 ++++ .../presentation/edit/ConfirmClearDialog.kt | 52 + .../ui/views/presentation/edit/EditScreen.kt | 1063 +++++++++++++++++ .../views/presentation/edit/EditViewModel.kt | 592 +++++++++ .../presentation/edit/MoreOptionsPopup.kt | 131 ++ .../edit/NewImageOptionsDialog.kt | 328 +++++ .../ui/views/presentation/edit/Operation.kt | 9 + .../views/presentation/edit/SavePathDialog.kt | 233 ++++ .../edit/TransparencyChessBoard.kt | 91 ++ .../edit/blur/BlurIntensityPopup.kt | 56 + .../presentation/edit/blur/BlurOperation.kt | 183 +++ .../edit/crop/CropAspectRatiosMenu.kt | 241 ++++ .../presentation/edit/crop/CropOperation.kt | 53 + .../presentation/edit/crop/CropWindow.kt | 443 +++++++ .../presentation/edit/draw/DrawOperation.kt | 37 + .../presentation/edit/resize/ResizeInput.kt | 201 ++++ .../edit/resize/ResizeOperation.kt | 114 ++ .../edit/rotate/RotateOperation.kt | 47 + .../views/presentation/main/EditActivity.kt | 149 +++ .../presentation/picker/FilePickerScreen.kt | 155 +++ .../ui/views/presentation/theme/Color.kt | 9 + .../ui/views/presentation/theme/Shape.kt | 11 + .../ui/views/presentation/theme/Theme.kt | 47 + .../ui/views/presentation/theme/Type.kt | 28 + .../views/presentation/utils/ImageHelper.kt | 33 + .../presentation/utils/PermissionsHelper.kt | 46 + .../ui/views/presentation/utils/Utils.kt | 106 ++ app/src/main/res/drawable/ic_arrow_back.xml | 5 + app/src/main/res/drawable/ic_aspect_ratio.xml | 5 + app/src/main/res/drawable/ic_blur_on.xml | 5 + app/src/main/res/drawable/ic_check.xml | 5 + app/src/main/res/drawable/ic_clear.xml | 5 + app/src/main/res/drawable/ic_crop.xml | 5 + app/src/main/res/drawable/ic_crop_16_9.xml | 5 + app/src/main/res/drawable/ic_crop_3_2.xml | 5 + app/src/main/res/drawable/ic_crop_5_4.xml | 5 + app/src/main/res/drawable/ic_crop_free.xml | 5 + app/src/main/res/drawable/ic_crop_square.xml | 5 + app/src/main/res/drawable/ic_eyedropper.xml | 4 + app/src/main/res/drawable/ic_insert_photo.xml | 5 + .../res/drawable/ic_launcher_1_foreground.xml | 180 +++ .../res/drawable/ic_launcher_2_foreground.xml | 47 + app/src/main/res/drawable/ic_line_weight.xml | 5 + app/src/main/res/drawable/ic_more_vert.xml | 5 + app/src/main/res/drawable/ic_pan_tool.xml | 5 + app/src/main/res/drawable/ic_redo.xml | 5 + .../res/drawable/ic_rotate_90_degrees_ccw.xml | 5 + app/src/main/res/drawable/ic_rotate_left.xml | 5 + app/src/main/res/drawable/ic_rotate_right.xml | 5 + app/src/main/res/drawable/ic_save.xml | 5 + app/src/main/res/drawable/ic_share.xml | 5 + app/src/main/res/drawable/ic_undo.xml | 5 + app/src/main/res/drawable/ic_zoom_in.xml | 6 + app/src/main/res/values/strings.xml | 36 + 64 files changed, 6257 insertions(+), 236 deletions(-) create mode 100644 app/src/main/java/dev/arkbuilders/arkmemo/di/AppComponent.kt create mode 100644 app/src/main/java/dev/arkbuilders/arkmemo/di/DIManager.kt create mode 100644 app/src/main/java/dev/arkbuilders/arkmemo/ui/views/data/ImageDefaults.kt create mode 100644 app/src/main/java/dev/arkbuilders/arkmemo/ui/views/data/Preferences.kt create mode 100644 app/src/main/java/dev/arkbuilders/arkmemo/ui/views/presentation/drawing/EditCanvas.kt create mode 100644 app/src/main/java/dev/arkbuilders/arkmemo/ui/views/presentation/drawing/EditManager.kt create mode 100644 app/src/main/java/dev/arkbuilders/arkmemo/ui/views/presentation/edit/ColorPickerDialog.kt create mode 100644 app/src/main/java/dev/arkbuilders/arkmemo/ui/views/presentation/edit/ConfirmClearDialog.kt create mode 100644 app/src/main/java/dev/arkbuilders/arkmemo/ui/views/presentation/edit/EditScreen.kt create mode 100644 app/src/main/java/dev/arkbuilders/arkmemo/ui/views/presentation/edit/EditViewModel.kt create mode 100644 app/src/main/java/dev/arkbuilders/arkmemo/ui/views/presentation/edit/MoreOptionsPopup.kt create mode 100644 app/src/main/java/dev/arkbuilders/arkmemo/ui/views/presentation/edit/NewImageOptionsDialog.kt create mode 100644 app/src/main/java/dev/arkbuilders/arkmemo/ui/views/presentation/edit/Operation.kt create mode 100644 app/src/main/java/dev/arkbuilders/arkmemo/ui/views/presentation/edit/SavePathDialog.kt create mode 100644 app/src/main/java/dev/arkbuilders/arkmemo/ui/views/presentation/edit/TransparencyChessBoard.kt create mode 100644 app/src/main/java/dev/arkbuilders/arkmemo/ui/views/presentation/edit/blur/BlurIntensityPopup.kt create mode 100644 app/src/main/java/dev/arkbuilders/arkmemo/ui/views/presentation/edit/blur/BlurOperation.kt create mode 100644 app/src/main/java/dev/arkbuilders/arkmemo/ui/views/presentation/edit/crop/CropAspectRatiosMenu.kt create mode 100644 app/src/main/java/dev/arkbuilders/arkmemo/ui/views/presentation/edit/crop/CropOperation.kt create mode 100644 app/src/main/java/dev/arkbuilders/arkmemo/ui/views/presentation/edit/crop/CropWindow.kt create mode 100644 app/src/main/java/dev/arkbuilders/arkmemo/ui/views/presentation/edit/draw/DrawOperation.kt create mode 100644 app/src/main/java/dev/arkbuilders/arkmemo/ui/views/presentation/edit/resize/ResizeInput.kt create mode 100644 app/src/main/java/dev/arkbuilders/arkmemo/ui/views/presentation/edit/resize/ResizeOperation.kt create mode 100644 app/src/main/java/dev/arkbuilders/arkmemo/ui/views/presentation/edit/rotate/RotateOperation.kt create mode 100644 app/src/main/java/dev/arkbuilders/arkmemo/ui/views/presentation/main/EditActivity.kt create mode 100644 app/src/main/java/dev/arkbuilders/arkmemo/ui/views/presentation/picker/FilePickerScreen.kt create mode 100644 app/src/main/java/dev/arkbuilders/arkmemo/ui/views/presentation/theme/Color.kt create mode 100644 app/src/main/java/dev/arkbuilders/arkmemo/ui/views/presentation/theme/Shape.kt create mode 100644 app/src/main/java/dev/arkbuilders/arkmemo/ui/views/presentation/theme/Theme.kt create mode 100644 app/src/main/java/dev/arkbuilders/arkmemo/ui/views/presentation/theme/Type.kt create mode 100644 app/src/main/java/dev/arkbuilders/arkmemo/ui/views/presentation/utils/ImageHelper.kt create mode 100644 app/src/main/java/dev/arkbuilders/arkmemo/ui/views/presentation/utils/PermissionsHelper.kt create mode 100644 app/src/main/java/dev/arkbuilders/arkmemo/ui/views/presentation/utils/Utils.kt create mode 100644 app/src/main/res/drawable/ic_arrow_back.xml create mode 100644 app/src/main/res/drawable/ic_aspect_ratio.xml create mode 100644 app/src/main/res/drawable/ic_blur_on.xml create mode 100644 app/src/main/res/drawable/ic_check.xml create mode 100644 app/src/main/res/drawable/ic_clear.xml create mode 100644 app/src/main/res/drawable/ic_crop.xml create mode 100644 app/src/main/res/drawable/ic_crop_16_9.xml create mode 100644 app/src/main/res/drawable/ic_crop_3_2.xml create mode 100644 app/src/main/res/drawable/ic_crop_5_4.xml create mode 100644 app/src/main/res/drawable/ic_crop_free.xml create mode 100644 app/src/main/res/drawable/ic_crop_square.xml create mode 100644 app/src/main/res/drawable/ic_eyedropper.xml create mode 100644 app/src/main/res/drawable/ic_insert_photo.xml create mode 100644 app/src/main/res/drawable/ic_launcher_1_foreground.xml create mode 100644 app/src/main/res/drawable/ic_launcher_2_foreground.xml create mode 100644 app/src/main/res/drawable/ic_line_weight.xml create mode 100644 app/src/main/res/drawable/ic_more_vert.xml create mode 100644 app/src/main/res/drawable/ic_pan_tool.xml create mode 100644 app/src/main/res/drawable/ic_redo.xml create mode 100644 app/src/main/res/drawable/ic_rotate_90_degrees_ccw.xml create mode 100644 app/src/main/res/drawable/ic_rotate_left.xml create mode 100644 app/src/main/res/drawable/ic_rotate_right.xml create mode 100644 app/src/main/res/drawable/ic_save.xml create mode 100644 app/src/main/res/drawable/ic_share.xml create mode 100644 app/src/main/res/drawable/ic_undo.xml create mode 100644 app/src/main/res/drawable/ic_zoom_in.xml diff --git a/app/build.gradle b/app/build.gradle index 56be7022..9fcee74a 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -6,6 +6,7 @@ plugins { id 'kotlin-parcelize' id 'com.google.gms.google-services' id 'com.google.firebase.crashlytics' + id 'org.jetbrains.kotlin.plugin.serialization' version '1.8.21' } android { @@ -72,9 +73,14 @@ android { jvmTarget = JavaVersion.VERSION_17.toString() } buildFeatures{ + compose true buildConfig true viewBinding true } + + composeOptions { + kotlinCompilerExtensionVersion = "1.5.1" + } } @@ -110,4 +116,20 @@ dependencies { implementation platform('com.google.firebase:firebase-bom:33.3.0') implementation "com.google.firebase:firebase-crashlytics" implementation "com.google.firebase:firebase-analytics" + def compose_version = "1.3.0" + 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" + } diff --git a/app/src/main/java/dev/arkbuilders/arkmemo/App.kt b/app/src/main/java/dev/arkbuilders/arkmemo/App.kt index 13be0d83..10a572b7 100644 --- a/app/src/main/java/dev/arkbuilders/arkmemo/App.kt +++ b/app/src/main/java/dev/arkbuilders/arkmemo/App.kt @@ -4,6 +4,7 @@ import android.app.Application import dagger.hilt.android.HiltAndroidApp import dev.arkbuilders.arklib.initArkLib import dev.arkbuilders.arkfilepicker.folders.FoldersRepo +import dev.arkbuilders.arkmemo.di.DIManager import dev.arkbuilders.arkmemo.preferences.MemoPreferences import javax.inject.Inject @@ -17,6 +18,7 @@ class App: Application() { super.onCreate() System.loadLibrary("arklib") initArkLib() + DIManager.init(this) FoldersRepo.init(this) } } diff --git a/app/src/main/java/dev/arkbuilders/arkmemo/di/AppComponent.kt b/app/src/main/java/dev/arkbuilders/arkmemo/di/AppComponent.kt new file mode 100644 index 00000000..b702ac40 --- /dev/null +++ b/app/src/main/java/dev/arkbuilders/arkmemo/di/AppComponent.kt @@ -0,0 +1,22 @@ +package dev.arkbuilders.arkmemo.di + +import android.app.Application +import android.content.Context +import dagger.BindsInstance +import dagger.Component +import dev.arkbuilders.arkmemo.ui.views.presentation.edit.EditViewModelFactory +import javax.inject.Singleton + +@Singleton +@Component +interface AppComponent { + fun editVMFactory(): EditViewModelFactory.Factory + fun app(): Application + @Component.Factory + interface Factory { + fun create( + @BindsInstance application: Application, + @BindsInstance context: Context + ): AppComponent + } +} diff --git a/app/src/main/java/dev/arkbuilders/arkmemo/di/DIManager.kt b/app/src/main/java/dev/arkbuilders/arkmemo/di/DIManager.kt new file mode 100644 index 00000000..b658b2cf --- /dev/null +++ b/app/src/main/java/dev/arkbuilders/arkmemo/di/DIManager.kt @@ -0,0 +1,12 @@ +package dev.arkbuilders.arkmemo.di + +import android.app.Application + +object DIManager { + lateinit var component: AppComponent + private set + + fun init(app: Application) { + component = DaggerAppComponent.factory().create(app, app.applicationContext) + } +} diff --git a/app/src/main/java/dev/arkbuilders/arkmemo/ui/fragments/BaseEditNoteFragment.kt b/app/src/main/java/dev/arkbuilders/arkmemo/ui/fragments/BaseEditNoteFragment.kt index 5886538e..6b107861 100644 --- a/app/src/main/java/dev/arkbuilders/arkmemo/ui/fragments/BaseEditNoteFragment.kt +++ b/app/src/main/java/dev/arkbuilders/arkmemo/ui/fragments/BaseEditNoteFragment.kt @@ -77,13 +77,13 @@ abstract class BaseEditNoteFragment: BaseFragment() { } } - if (this is EditGraphicNotesFragment) { - binding.toolbar.tvRightActionText.visible() - binding.toolbar.ivRightActionIcon.gone() - } else { - binding.toolbar.tvRightActionText.gone() - binding.toolbar.ivRightActionIcon.visible() - } +// if (this is EditGraphicNotesFragment) { +// binding.toolbar.tvRightActionText.visible() +// binding.toolbar.ivRightActionIcon.gone() +// } else { +// binding.toolbar.tvRightActionText.gone() +// binding.toolbar.ivRightActionIcon.visible() +// } if (getCurrentNote().resource == null) { binding.tvLastModified.gone() diff --git a/app/src/main/java/dev/arkbuilders/arkmemo/ui/fragments/EditGraphicNotesFragment.kt b/app/src/main/java/dev/arkbuilders/arkmemo/ui/fragments/EditGraphicNotesFragment.kt index 58fe3eee..ebe10c27 100644 --- a/app/src/main/java/dev/arkbuilders/arkmemo/ui/fragments/EditGraphicNotesFragment.kt +++ b/app/src/main/java/dev/arkbuilders/arkmemo/ui/fragments/EditGraphicNotesFragment.kt @@ -4,9 +4,15 @@ import android.os.Bundle import android.text.Editable import android.text.TextWatcher import android.util.Log +import android.view.LayoutInflater import android.view.View +import android.view.ViewGroup +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.ComposeView import androidx.core.view.isVisible import androidx.fragment.app.viewModels +import androidx.navigation.findNavController import androidx.recyclerview.widget.LinearLayoutManager import dagger.hilt.android.AndroidEntryPoint import dev.arkbuilders.arkmemo.R @@ -29,6 +35,8 @@ import dev.arkbuilders.arkmemo.ui.adapters.BrushSizeSmall import dev.arkbuilders.arkmemo.ui.adapters.BrushSizeTiny import dev.arkbuilders.arkmemo.ui.adapters.EqualSpacingItemDecoration import dev.arkbuilders.arkmemo.ui.viewmodels.GraphicNotesViewModel +import dev.arkbuilders.arkmemo.ui.views.data.Resolution +import dev.arkbuilders.arkmemo.ui.views.presentation.edit.EditScreen import dev.arkbuilders.arkmemo.utils.getBrushSize import dev.arkbuilders.arkmemo.utils.getColorCode import dev.arkbuilders.arkmemo.utils.getParcelableCompat @@ -38,239 +46,28 @@ import dev.arkbuilders.arkmemo.utils.setDrawableColor import dev.arkbuilders.arkmemo.utils.visible @AndroidEntryPoint -class EditGraphicNotesFragment: BaseEditNoteFragment() { - - private val graphicNotesViewModel: GraphicNotesViewModel by viewModels() - private var note = GraphicNote() - - private val colorBrushes by lazy { - listOf( - BrushColorBlack, BrushColorGrey, BrushColorRed, - BrushColorOrange, BrushColorGreen, BrushColorBlue, BrushColorPurple) - } - - private val sizeBrushes by lazy { - listOf( - BrushSizeTiny, BrushSizeSmall, BrushSizeMedium, - BrushSizeLarge, BrushSizeHuge) +class EditGraphicNotesFragment: BaseFragment() { + override fun onBackPressed() { + TODO("Not yet implemented") } - override fun onCreate(savedInstanceState: Bundle?) { - super.onCreate(savedInstanceState) - notesViewModel.init {} - observeSaveResult(notesViewModel.getSaveNoteResultLiveData()) - arguments?.getParcelableCompat(GRAPHICAL_NOTE_KEY, GraphicNote::class.java)?.let { - note = it - graphicNotesViewModel.onNoteOpened(note) - } - } - - override fun onViewCreated(view: View, savedInstanceState: Bundle?) { - super.onViewCreated(view, savedInstanceState) - var title = note.title - val notesCanvas = binding.notesCanvas - val btnSave = binding.toolbar.tvRightActionText - val noteTitle = binding.edtTitle - val noteTitleChangeListener = object: TextWatcher { - override fun beforeTextChanged(s: CharSequence?, start: Int, count: Int, after: Int) {} - - override fun onTextChanged(s: CharSequence?, start: Int, before: Int, count: Int) { - title = s?.toString() ?: "" - if (title.isEmpty()) { - binding.edtTitle.hint = getString(R.string.hint_new_graphical_note) - } - enableSaveText(isContentChanged() && !isContentEmpty()) - } - - override fun afterTextChanged(s: Editable?) {} - - } - - hostActivity.title = getString(R.string.edit_note) - hostActivity.supportActionBar?.setDisplayHomeAsUpEnabled(true) - - noteTitle.hint = getString(R.string.hint_new_graphical_note) - noteTitle.setText(title) - noteTitle.addTextChangedListener(noteTitleChangeListener) - notesCanvas.isVisible = true - notesCanvas.setViewModel(graphicNotesViewModel.apply { - colorBrushes.firstOrNull { it.isSelected }?.let { color -> - val colorCode = color.getColorCode() - setPaintColor(colorCode) - binding.layoutGraphicsControl.tvBrushColor.setDrawableColor(colorCode) - } - - sizeBrushes.firstOrNull { it.isSelected }?.let { - setBrushSize(it.getBrushSize()) - } - }) - btnSave.setOnClickListener { - val note = createNewNote() - notesViewModel.onSaveClick(note, parentNote = this.note) { show -> - hostActivity.showProgressBar(show) + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle? + ): View? { + return ComposeView(requireContext()).apply { + setContent { + EditScreen( + null, + null, + childFragmentManager, + {onBackPressed()}, + true, + Resolution(120, 120) + ) } } - enableSaveText(false) - - binding.tvLastModified.gone() - binding.editTextDescription.setText(this.note.description) - initBottomControls() - observeDrawEvent() - } - - private fun observeDrawEvent() { - graphicNotesViewModel.observableSvgLiveData.observe(viewLifecycleOwner) { - enableSaveText(it.getPaths().isNotEmpty()) - } - } - - override fun createNewNote(): Note { - return GraphicNote( - title = binding.edtTitle.text.toString(), - svg = graphicNotesViewModel.svg(), - description = binding.editTextDescription.text.toString(), - resource = note.resource - ) - } - - override fun getCurrentNote(): Note { - return note - } - - override fun isContentChanged(): Boolean { - val originalPaths = note.svg?.getPaths() ?: emptyList() - val newPaths = graphicNotesViewModel.svg().getPaths() - - return note.title != binding.edtTitle.text.toString() - || ((newPaths.size != originalPaths.size) || (!newPaths.containsAll(originalPaths))) - } - - override fun isContentEmpty(): Boolean { - return graphicNotesViewModel.svg().getPaths().isEmpty() - } - - private fun initBottomControls() { - val tvBrushSize = binding.layoutGraphicsControl.tvBrushSize - tvBrushSize.setOnClickListener { - tvBrushSize.isSelected = !tvBrushSize.isSelected - if (tvBrushSize.isSelected) { - binding.layoutGraphicsControl.layoutSizeChooser.root.visible() - showBrushSizeList() - binding.layoutGraphicsControl.layoutColorChooser.root.gone() - binding.layoutGraphicsControl.tvEraser.isSelected = false - binding.layoutGraphicsControl.tvBrushColor.isSelected = false - graphicNotesViewModel.setEraseMode(false) - } else { - binding.layoutGraphicsControl.layoutSizeChooser.root.gone() - } - } - - val tvEraser = binding.layoutGraphicsControl.tvEraser - tvEraser.setOnClickListener { - tvEraser.isSelected = !tvEraser.isSelected - if (tvEraser.isSelected) { - binding.layoutGraphicsControl.layoutSizeChooser.root.visible() - showBrushSizeList(isEraseMode = true) - binding.layoutGraphicsControl.layoutColorChooser.root.gone() - binding.layoutGraphicsControl.tvBrushSize.isSelected = false - binding.layoutGraphicsControl.tvBrushColor.isSelected = false - } else { - binding.layoutGraphicsControl.layoutSizeChooser.root.gone() - graphicNotesViewModel.setEraseMode(false) - } - graphicNotesViewModel.setEraseMode(tvEraser.isSelected) - } - - val tvColor = binding.layoutGraphicsControl.tvBrushColor - tvColor.setOnClickListener { - tvColor.isSelected = !tvColor.isSelected - if (tvColor.isSelected) { - showBrushColorList() - binding.layoutGraphicsControl.layoutColorChooser.root.visible() - binding.layoutGraphicsControl.layoutSizeChooser.root.gone() - binding.layoutGraphicsControl.tvBrushSize.isSelected = false - binding.layoutGraphicsControl.tvEraser.isSelected = false - graphicNotesViewModel.setEraseMode(false) - } else { - binding.layoutGraphicsControl.layoutColorChooser.root.gone() - } - } - } - - override fun onResume() { - super.onResume() - hostActivity.fragment = this - } - - private fun showBrushSizeList(isEraseMode: Boolean = false) { - - val brushSizeAdapter = BrushAdapter( - attributes = sizeBrushes.apply { - val selectedIndex = this.indexOfFirst { it.isSelected } - if (selectedIndex == -1) { - sizeBrushes[0].isSelected = true - } - }, - onItemClick = { attribute, pos -> - Log.v(TAG, "onSizeSelected: " + attribute) - graphicNotesViewModel.setBrushSize((attribute as BrushSize).getBrushSize()) - graphicNotesViewModel.setEraseMode(isEraseMode) - } - ) - - val layoutMgr = LinearLayoutManager(context, LinearLayoutManager.HORIZONTAL, false) - binding.layoutGraphicsControl.layoutSizeChooser.rvBrushSizes.apply { - while (this.itemDecorationCount > 0) { - this.removeItemDecorationAt(0) - } - addItemDecoration(EqualSpacingItemDecoration(context.resources.getDimensionPixelSize( - R.dimen.brush_size_item_margin), EqualSpacingItemDecoration.HORIZONTAL) - ) - this.isNestedScrollingEnabled = false - layoutManager = layoutMgr - adapter = brushSizeAdapter - } - } - - private fun showBrushColorList() { - - val brushColorAdapter = BrushAdapter( - attributes = colorBrushes.apply { - val selectedIndex = this.indexOfFirst { it.isSelected } - if (selectedIndex == -1) { - colorBrushes[0].isSelected = true - } - }, - onItemClick = { attribute, pos -> - Log.v(TAG, "onColorSelected: " + attribute) - (attribute as BrushColor).getColorCode().let { colorCode -> - graphicNotesViewModel.setPaintColor(colorCode) - binding.layoutGraphicsControl.tvBrushColor.setDrawableColor(colorCode) - } - } - ) - - val layoutMgr = LinearLayoutManager(context, LinearLayoutManager.HORIZONTAL, false) - binding.layoutGraphicsControl.layoutColorChooser.rvBrushColors.apply { - while (this.itemDecorationCount > 0) { - this.removeItemDecorationAt(0) - } - addItemDecoration(EqualSpacingItemDecoration(context.resources.getDimensionPixelSize( - R.dimen.brush_color_item_margin), EqualSpacingItemDecoration.HORIZONTAL) - ) - this.isNestedScrollingEnabled = false - layoutManager = layoutMgr - adapter = brushColorAdapter - } - } - - private fun enableSaveText(enabled: Boolean) { - binding.toolbar.tvRightActionText.isEnabled = enabled - if (enabled) { - binding.toolbar.tvRightActionText.alpha = 1f - } else { - binding.toolbar.tvRightActionText.alpha = 0.4f - } } companion object { diff --git a/app/src/main/java/dev/arkbuilders/arkmemo/ui/views/data/ImageDefaults.kt b/app/src/main/java/dev/arkbuilders/arkmemo/ui/views/data/ImageDefaults.kt new file mode 100644 index 00000000..14e37e82 --- /dev/null +++ b/app/src/main/java/dev/arkbuilders/arkmemo/ui/views/data/ImageDefaults.kt @@ -0,0 +1,23 @@ +package dev.arkbuilders.arkmemo.ui.views.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/app/src/main/java/dev/arkbuilders/arkmemo/ui/views/data/Preferences.kt b/app/src/main/java/dev/arkbuilders/arkmemo/ui/views/data/Preferences.kt new file mode 100644 index 00000000..83f0482d --- /dev/null +++ b/app/src/main/java/dev/arkbuilders/arkmemo/ui/views/data/Preferences.kt @@ -0,0 +1,92 @@ +package dev.arkbuilders.arkmemo.ui.views.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 jsonString = Json.encodeToString(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/app/src/main/java/dev/arkbuilders/arkmemo/ui/views/presentation/drawing/EditCanvas.kt b/app/src/main/java/dev/arkbuilders/arkmemo/ui/views/presentation/drawing/EditCanvas.kt new file mode 100644 index 00000000..7fe9c4df --- /dev/null +++ b/app/src/main/java/dev/arkbuilders/arkmemo/ui/views/presentation/drawing/EditCanvas.kt @@ -0,0 +1,335 @@ +package dev.arkbuilders.arkmemo.ui.views.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.arkmemo.ui.views.presentation.edit.EditViewModel +import dev.arkbuilders.arkmemo.ui.views.presentation.edit.TransparencyChessBoardCanvas +import dev.arkbuilders.arkmemo.ui.views.presentation.edit.crop.CropWindow.Companion.computeDeltaX +import dev.arkbuilders.arkmemo.ui.views.presentation.edit.crop.CropWindow.Companion.computeDeltaY +import dev.arkbuilders.arkmemo.ui.views.presentation.picker.toDp +import dev.arkbuilders.arkmemo.ui.views.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() + } + } + MotionEvent.ACTION_MOVE -> { + path.quadraticBezierTo( + currentPoint.x, + currentPoint.y, + (eventX + currentPoint.x) / 2, + (eventY + currentPoint.y) / 2 + ) + 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 -> {} + } + } + + 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/app/src/main/java/dev/arkbuilders/arkmemo/ui/views/presentation/drawing/EditManager.kt b/app/src/main/java/dev/arkbuilders/arkmemo/ui/views/presentation/drawing/EditManager.kt new file mode 100644 index 00000000..90c493ac --- /dev/null +++ b/app/src/main/java/dev/arkbuilders/arkmemo/ui/views/presentation/drawing/EditManager.kt @@ -0,0 +1,621 @@ +package dev.arkbuilders.arkmemo.ui.views.presentation.drawing + +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.graphics.BlendMode +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.ImageBitmap +import androidx.compose.ui.graphics.Paint +import androidx.compose.ui.graphics.PaintingStyle +import androidx.compose.ui.graphics.Path +import androidx.compose.ui.graphics.StrokeCap +import androidx.compose.ui.graphics.StrokeJoin +import androidx.compose.ui.unit.IntSize +import dev.arkbuilders.arkmemo.ui.views.data.ImageDefaults +import dev.arkbuilders.arkmemo.ui.views.data.Resolution +import dev.arkbuilders.arkmemo.ui.views.presentation.edit.ImageViewParams +import dev.arkbuilders.arkmemo.ui.views.presentation.edit.Operation +import dev.arkbuilders.arkmemo.ui.views.presentation.edit.blur.BlurOperation +import dev.arkbuilders.arkmemo.ui.views.presentation.edit.crop.CropOperation +import dev.arkbuilders.arkmemo.ui.views.presentation.edit.crop.CropWindow +import dev.arkbuilders.arkmemo.ui.views.presentation.edit.draw.DrawOperation +import dev.arkbuilders.arkmemo.ui.views.presentation.edit.fitBackground +import dev.arkbuilders.arkmemo.ui.views.presentation.edit.fitImage +import dev.arkbuilders.arkmemo.ui.views.presentation.edit.resize.ResizeOperation +import dev.arkbuilders.arkmemo.ui.views.presentation.edit.rotate.RotateOperation +import java.util.Stack + +class EditManager { + private val drawPaint: MutableState = mutableStateOf(defaultPaint()) + + private val _paintColor: MutableState = + mutableStateOf(drawPaint.value.color) + 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) + + private 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 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 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() { + 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 cancelResizeMode() { + backgroundImage.value = backgroundImage2.value + editMatrix.reset() + } + + fun toggleBlurMode() { + _isBlurMode.value = !isBlurMode.value + } + 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 + ) + 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/app/src/main/java/dev/arkbuilders/arkmemo/ui/views/presentation/edit/ColorPickerDialog.kt b/app/src/main/java/dev/arkbuilders/arkmemo/ui/views/presentation/edit/ColorPickerDialog.kt new file mode 100644 index 00000000..c078efef --- /dev/null +++ b/app/src/main/java/dev/arkbuilders/arkmemo/ui/views/presentation/edit/ColorPickerDialog.kt @@ -0,0 +1,254 @@ +package dev.arkbuilders.arkmemo.ui.views.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 kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.launch +import dev.arkbuilders.arkmemo.R + +@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/app/src/main/java/dev/arkbuilders/arkmemo/ui/views/presentation/edit/ConfirmClearDialog.kt b/app/src/main/java/dev/arkbuilders/arkmemo/ui/views/presentation/edit/ConfirmClearDialog.kt new file mode 100644 index 00000000..350182a7 --- /dev/null +++ b/app/src/main/java/dev/arkbuilders/arkmemo/ui/views/presentation/edit/ConfirmClearDialog.kt @@ -0,0 +1,52 @@ +package dev.arkbuilders.arkmemo.ui.views.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/app/src/main/java/dev/arkbuilders/arkmemo/ui/views/presentation/edit/EditScreen.kt b/app/src/main/java/dev/arkbuilders/arkmemo/ui/views/presentation/edit/EditScreen.kt new file mode 100644 index 00000000..737a72b2 --- /dev/null +++ b/app/src/main/java/dev/arkbuilders/arkmemo/ui/views/presentation/edit/EditScreen.kt @@ -0,0 +1,1063 @@ +@file:OptIn(ExperimentalComposeUiApi::class) + +package dev.arkbuilders.arkmemo.ui.views.presentation.edit + +import android.os.Build +import android.view.MotionEvent +import android.widget.Toast +import androidx.activity.compose.BackHandler +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.clickable +import androidx.compose.foundation.background +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.horizontalScroll +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +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.layout.height +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.IntrinsicSize +import androidx.compose.foundation.layout.wrapContentHeight +import androidx.compose.foundation.layout.BoxScope +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.MaterialTheme +import androidx.compose.material.Icon +import androidx.compose.material.Text +import androidx.compose.material.TextButton +import androidx.compose.material.AlertDialog +import androidx.compose.material.Button +import androidx.compose.material.Slider +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.remember +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.LaunchedEffect +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.asAndroidBitmap +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 androidx.lifecycle.viewmodel.compose.viewModel +import dev.arkbuilders.arkmemo.R +import dev.arkbuilders.arkmemo.ui.views.data.Resolution +import dev.arkbuilders.arkmemo.ui.views.presentation.drawing.EditCanvas +import dev.arkbuilders.arkmemo.ui.views.presentation.edit.blur.BlurIntensityPopup +import dev.arkbuilders.arkmemo.ui.views.presentation.edit.crop.CropAspectRatiosMenu +import dev.arkbuilders.arkmemo.ui.views.presentation.edit.resize.Hint +import dev.arkbuilders.arkmemo.ui.views.presentation.edit.resize.ResizeInput +import dev.arkbuilders.arkmemo.ui.views.presentation.edit.resize.delayHidingHint +import dev.arkbuilders.arkmemo.di.DIManager +import dev.arkbuilders.arkmemo.ui.views.presentation.picker.toPx +import dev.arkbuilders.arkmemo.ui.views.presentation.theme.Gray +import dev.arkbuilders.arkmemo.ui.views.presentation.utils.askWritePermissions +import dev.arkbuilders.arkmemo.ui.views.presentation.utils.getActivity +import dev.arkbuilders.arkmemo.ui.views.presentation.utils.isWritePermGranted +import java.nio.file.Path + +@Composable +fun EditScreen( + imagePath: Path?, + imageUri: String?, + fragmentManager: FragmentManager, + navigateBack: () -> Unit, + launchedFromIntent: Boolean, + maxResolution: Resolution +) { + val primaryColor = MaterialTheme.colors.primary.value.toLong() + val viewModel: EditViewModel = + viewModel( + factory = DIManager + .component + .editVMFactory() + .create( + primaryColor, + launchedFromIntent, + imagePath, + imageUri, + maxResolution + ) + ) + val context = LocalContext.current + val showDefaultsDialog = remember { + mutableStateOf( + imagePath == null && imageUri == null && !viewModel.isLoaded + ) + } + + if (showDefaultsDialog.value) { + viewModel.editManager.apply { + resolution.value?.let { + NewImageOptionsDialog( + it, + maxResolution, + this.backgroundColor.value, + navigateBack, + this, + persistDefaults = { color, resolution -> + viewModel.persistDefaults(color, resolution) + }, + onConfirm = { + showDefaultsDialog.value = false + } + ) + } + } + } + ExitDialog( + viewModel = viewModel, + navigateBack = { + navigateBack() + viewModel.isLoaded = false + }, + launchedFromIntent = launchedFromIntent, + ) + + BackHandler { + val editManager = viewModel.editManager + if ( + editManager.isCropMode.value || editManager.isRotateMode.value || + editManager.isResizeMode.value || editManager.isEyeDropperMode.value || + editManager.isBlurMode.value + ) { + viewModel.cancelOperation() + return@BackHandler + } + if (editManager.isZoomMode.value) { + editManager.toggleZoomMode() + return@BackHandler + } + if (editManager.isPanMode.value) { + editManager.togglePanMode() + return@BackHandler + } + if (editManager.canUndo.value) { + editManager.undo() + return@BackHandler + } + if (viewModel.exitConfirmed) { + if (launchedFromIntent) + context.getActivity()?.finish() + else + navigateBack() + return@BackHandler + } + if (!viewModel.exitConfirmed) { + Toast.makeText(context, "Tap back again to exit", Toast.LENGTH_SHORT) + .show() + viewModel.confirmExit() + return@BackHandler + } + } + + 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 + } + } + } +} + +@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 = MaterialTheme.colors.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 = MaterialTheme.colors.primary, + contentDescription = null + ) + } + + EditMenuContainer(viewModel, navigateBack) + } + } +} + +@OptIn(ExperimentalComposeUiApi::class) +@Composable +private fun DrawContainer( + viewModel: EditViewModel +) { + 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) return@onSizeChanged + if (viewModel.showSavePathDialog) return@onSizeChanged + viewModel.editManager.drawAreaSize.value = newSize + if (viewModel.isLoaded) { + viewModel.editManager.apply { + when (true) { + isCropMode.value -> { + cropWindow.updateOnDrawAreaSizeChange(newSize) + return@onSizeChanged + } + + 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@onSizeChanged + } + + isRotateMode.value -> { + scaleToFitOnEdit() + return@onSizeChanged + } + + isZoomMode.value -> { return@onSizeChanged } + + else -> { + scaleToFit() + return@onSizeChanged + } + } + } + } + viewModel.loadImage() + }, + 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(context, 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.apply { + if ( + !isRotateMode.value && + !isResizeMode.value && + !isCropMode.value && + !isEyeDropperMode.value + ) clearEdits() + } + } + ) + + if ( + !viewModel.menusVisible && + !viewModel.editManager.isRotateMode.value && + !viewModel.editManager.isResizeMode.value && + !viewModel.editManager.isCropMode.value && + !viewModel.editManager.isEyeDropperMode.value + ) + return + Icon( + modifier = Modifier + .align(Alignment.TopStart) + .padding(8.dp) + .size(36.dp) + .clip(CircleShape) + .clickable { + viewModel.editManager.apply { + if ( + isCropMode.value || isRotateMode.value || + isResizeMode.value || isEyeDropperMode.value || + isBlurMode.value + ) { + viewModel.cancelOperation() + return@clickable + } + if (isZoomMode.value) { + toggleZoomMode() + return@clickable + } + if (isPanMode.value) { + togglePanMode() + return@clickable + } + if ( + !viewModel.editManager.canUndo.value + ) { + if (launchedFromIntent) { + context + .getActivity() + ?.finish() + } else { + navigateBack() + } + } else { + viewModel.showExitDialog = true + } + } + }, + imageVector = ImageVector.vectorResource(R.drawable.ic_arrow_back), + tint = MaterialTheme.colors.primary, + contentDescription = null + ) + + Row( + Modifier + .align(Alignment.TopEnd) + ) { + Icon( + modifier = Modifier + .padding(8.dp) + .size(36.dp) + .clip(CircleShape) + .clickable { + viewModel.editManager.apply { + if ( + isCropMode.value || isRotateMode.value || + isResizeMode.value || isBlurMode.value + ) { + viewModel.applyOperation() + return@clickable + } + } + viewModel.showMoreOptionsPopup = true + }, + imageVector = if ( + viewModel.editManager.isCropMode.value || + viewModel.editManager.isRotateMode.value || + viewModel.editManager.isResizeMode.value || + viewModel.editManager.isBlurMode.value + ) + ImageVector.vectorResource(R.drawable.ic_check) + else ImageVector.vectorResource(R.drawable.ic_more_vert), + tint = MaterialTheme.colors.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.isRotateMode.value && + !editManager.isResizeMode.value && + !editManager.isCropMode.value && + !editManager.isEyeDropperMode.value && + !editManager.isBlurMode.value + ) { + editManager.undo() + } + }, + imageVector = ImageVector.vectorResource(R.drawable.ic_undo), + tint = if ( + editManager.canUndo.value && ( + !editManager.isRotateMode.value && + !editManager.isResizeMode.value && + !editManager.isCropMode.value && + !editManager.isEyeDropperMode.value && + !editManager.isBlurMode.value + ) + ) MaterialTheme.colors.primary else Color.Black, + contentDescription = null + ) + Icon( + modifier = Modifier + .padding(12.dp) + .size(40.dp) + .clip(CircleShape) + .clickable { + if ( + !editManager.isRotateMode.value && + !editManager.isResizeMode.value && + !editManager.isCropMode.value && + !editManager.isEyeDropperMode.value && + !editManager.isBlurMode.value + ) editManager.redo() + }, + imageVector = ImageVector.vectorResource(R.drawable.ic_redo), + tint = if ( + editManager.canRedo.value && + ( + !editManager.isRotateMode.value && + !editManager.isResizeMode.value && + !editManager.isCropMode.value && + !editManager.isEyeDropperMode.value && + !editManager.isBlurMode.value + ) + ) MaterialTheme.colors.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.isRotateMode.value && + !editManager.isResizeMode.value && + !editManager.isCropMode.value && + !editManager.isEraseMode.value && + !editManager.isBlurMode.value + ) + 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.isRotateMode.value && + !editManager.isCropMode.value && + !editManager.isResizeMode.value && + !editManager.isEyeDropperMode.value && + !editManager.isBlurMode.value + ) + viewModel.strokeSliderExpanded = + !viewModel.strokeSliderExpanded + }, + imageVector = + ImageVector.vectorResource(R.drawable.ic_line_weight), + tint = if ( + !editManager.isRotateMode.value && + !editManager.isResizeMode.value && + !editManager.isCropMode.value && + !editManager.isEyeDropperMode.value && + !editManager.isBlurMode.value + ) editManager.paintColor.value + else Color.Black, + contentDescription = null + ) + Icon( + modifier = Modifier + .padding(12.dp) + .size(40.dp) + .clip(CircleShape) + .clickable { + if ( + !editManager.isRotateMode.value && + !editManager.isResizeMode.value && + !editManager.isCropMode.value && + !editManager.isEyeDropperMode.value && + !editManager.isBlurMode.value + ) + editManager.toggleEraseMode() + }, + imageVector = ImageVector.vectorResource(R.drawable.ic_eraser), + tint = if ( + editManager.isEraseMode.value + ) + MaterialTheme.colors.primary + else + Color.Black, + contentDescription = null + ) + Icon( + modifier = Modifier + .padding(12.dp) + .size(40.dp) + .clip(CircleShape) + .clickable { + if ( + !editManager.isRotateMode.value && + !editManager.isResizeMode.value && + !editManager.isCropMode.value && + !editManager.isEyeDropperMode.value && + !editManager.isBlurMode.value && + !editManager.isEraseMode.value + ) + editManager.toggleZoomMode() + }, + imageVector = ImageVector.vectorResource(R.drawable.ic_zoom_in), + tint = if ( + editManager.isZoomMode.value + ) + MaterialTheme.colors.primary + else + Color.Black, + contentDescription = null + ) + Icon( + modifier = Modifier + .padding(12.dp) + .size(40.dp) + .clip(CircleShape) + .clickable { + if ( + !editManager.isRotateMode.value && + !editManager.isResizeMode.value && + !editManager.isCropMode.value && + !editManager.isEyeDropperMode.value && + !editManager.isBlurMode.value && + !editManager.isEraseMode.value + ) + editManager.togglePanMode() + }, + imageVector = ImageVector.vectorResource(R.drawable.ic_pan_tool), + tint = if ( + editManager.isPanMode.value + ) + MaterialTheme.colors.primary + else + Color.Black, + contentDescription = null + ) + Icon( + modifier = Modifier + .padding(12.dp) + .size(40.dp) + .clip(CircleShape) + .clickable { + editManager.apply { + if ( + !isRotateMode.value && + !isResizeMode.value && + !isEyeDropperMode.value && + !isEraseMode.value && + !isBlurMode.value + ) { + toggleCropMode() + viewModel.menusVisible = + !editManager.isCropMode.value + if (isCropMode.value) { + val bitmap = viewModel.getEditedImage() + setBackgroundImage2() + backgroundImage.value = bitmap + viewModel.editManager.cropWindow.init( + bitmap.asAndroidBitmap() + ) + return@clickable + } + editManager.cancelCropMode() + editManager.scaleToFit() + editManager.cropWindow.close() + } + } + }, + imageVector = ImageVector.vectorResource(R.drawable.ic_crop), + tint = if ( + editManager.isCropMode.value + ) MaterialTheme.colors.primary + else + Color.Black, + contentDescription = null + ) + Icon( + modifier = Modifier + .padding(12.dp) + .size(40.dp) + .clip(CircleShape) + .clickable { + editManager.apply { + if ( + !isCropMode.value && + !isResizeMode.value && + !isEyeDropperMode.value && + !isEraseMode.value && + !isBlurMode.value + ) { + toggleRotateMode() + if (isRotateMode.value) { + setBackgroundImage2() + viewModel.menusVisible = + !editManager.isRotateMode.value + scaleToFitOnEdit() + return@clickable + } + cancelRotateMode() + scaleToFit() + } + } + }, + imageVector = ImageVector + .vectorResource(R.drawable.ic_rotate_90_degrees_ccw), + tint = if (editManager.isRotateMode.value) + MaterialTheme.colors.primary + else + Color.Black, + contentDescription = null + ) + Icon( + modifier = Modifier + .padding(12.dp) + .size(40.dp) + .clip(CircleShape) + .clickable { + editManager.apply { + if ( + !isRotateMode.value && + !isCropMode.value && + !isEyeDropperMode.value && + !isEraseMode.value && + !isBlurMode.value + ) + toggleResizeMode() + else return@clickable + viewModel.menusVisible = !isResizeMode.value + if (isResizeMode.value) { + setBackgroundImage2() + val imgBitmap = viewModel.getEditedImage() + backgroundImage.value = imgBitmap + resizeOperation.init( + imgBitmap.asAndroidBitmap() + ) + return@clickable + } + cancelResizeMode() + scaleToFit() + } + }, + imageVector = ImageVector + .vectorResource(R.drawable.ic_aspect_ratio), + tint = if (editManager.isResizeMode.value) + MaterialTheme.colors.primary + else + Color.Black, + contentDescription = null + ) + Icon( + modifier = Modifier + .padding(12.dp) + .size(40.dp) + .clip(CircleShape) + .clickable { + editManager.apply { + if ( + !isRotateMode.value && + !isCropMode.value && + !isEyeDropperMode.value && + !isResizeMode.value && + !isEraseMode.value && + !viewModel.strokeSliderExpanded + ) toggleBlurMode() + if (isBlurMode.value) { + setBackgroundImage2() + backgroundImage.value = viewModel.getEditedImage() + blurOperation.init() + return@clickable + } + blurOperation.cancel() + scaleToFit() + } + }, + imageVector = ImageVector + .vectorResource(R.drawable.ic_blur_on), + tint = if (editManager.isBlurMode.value) + MaterialTheme.colors.primary + else + Color.Black, + contentDescription = null + ) + } + } + viewModel.bottomButtonsScrollIsAtStart.value = scrollState.value == 0 + viewModel.bottomButtonsScrollIsAtEnd.value = + scrollState.value == 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 + } + ) { + Text("Save") + } + }, + dismissButton = { + TextButton( + onClick = { + viewModel.showExitDialog = false + if (launchedFromIntent) { + context.getActivity()?.finish() + } else { + navigateBack() + } + } + ) { + Text("Exit") + } + } + ) +} diff --git a/app/src/main/java/dev/arkbuilders/arkmemo/ui/views/presentation/edit/EditViewModel.kt b/app/src/main/java/dev/arkbuilders/arkmemo/ui/views/presentation/edit/EditViewModel.kt new file mode 100644 index 00000000..63edb036 --- /dev/null +++ b/app/src/main/java/dev/arkbuilders/arkmemo/ui/views/presentation/edit/EditViewModel.kt @@ -0,0 +1,592 @@ +package dev.arkbuilders.arkmemo.ui.views.presentation.edit + +import android.content.Context +import android.content.Intent +import android.graphics.Bitmap +import android.graphics.Matrix +import android.graphics.drawable.Drawable +import android.media.MediaScannerConnection +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.Color +import androidx.compose.ui.graphics.ImageBitmap +import androidx.compose.ui.graphics.ImageBitmapConfig +import androidx.compose.ui.graphics.Canvas +import androidx.compose.ui.graphics.Paint +import androidx.compose.ui.graphics.asAndroidBitmap +import androidx.compose.ui.graphics.nativeCanvas +import androidx.compose.ui.graphics.asImageBitmap +import androidx.compose.ui.unit.IntSize +import androidx.compose.ui.unit.toSize +import androidx.core.content.FileProvider +import androidx.core.net.toUri +import androidx.lifecycle.ViewModel +import androidx.lifecycle.ViewModelProvider +import androidx.lifecycle.viewModelScope +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 dagger.assisted.Assisted +import dagger.assisted.AssistedFactory +import dagger.assisted.AssistedInject +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch +import dev.arkbuilders.arkmemo.R +import dev.arkbuilders.arkmemo.ui.views.data.Preferences +import dev.arkbuilders.arkmemo.ui.views.data.Resolution +import dev.arkbuilders.arkmemo.di.DIManager +import dev.arkbuilders.arkmemo.ui.views.presentation.drawing.EditManager +import dev.arkbuilders.arkmemo.ui.views.presentation.edit.resize.ResizeOperation +import timber.log.Timber +import java.io.File +import java.nio.file.Path +import kotlin.io.path.outputStream +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 +) : ViewModel() { + val editManager = EditManager() + + 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 + + init { + if (imageUri == null && imagePath == null) { + viewModelScope.launch { + editManager.initDefaults( + prefs.readDefaults(), + maxResolution + ) + } + } + viewModelScope.launch { + _usedColors.addAll(prefs.readUsedColors()) + + val color = if (_usedColors.isNotEmpty()) { + _usedColors.last() + } else { + val defaultColor = Color(primaryColor.toULong()) + + _usedColors.add(defaultColor) + defaultColor + } + + editManager.setPaintColor(color) + } + } + + fun loadImage() { + isLoaded = true + imagePath?.let { + loadImageWithPath( + DIManager.component.app(), + imagePath, + editManager + ) + return + } + imageUri?.let { + loadImageWithUri( + DIManager.component.app(), + imageUri, + editManager + ) + return + } + editManager.scaleToFit() + } + + fun saveImage(context: Context, path: Path) { + viewModelScope.launch(Dispatchers.IO) { + isSavingImage = true + val combinedBitmap = getEditedImage() + + path.outputStream().use { out -> + combinedBitmap.asAndroidBitmap() + .compress(Bitmap.CompressFormat.PNG, 100, out) + } + MediaScannerConnection.scanFile( + context, + arrayOf(path.toString()), + arrayOf("image/*") + ) { _, _ -> } + 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, + getString(R.string.share) + ) + ) + } + } + + fun getImageUri( + context: Context = DIManager.component.app(), + 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 { + 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 + } + 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 + } +} + +class EditViewModelFactory @AssistedInject constructor( + @Assisted private val primaryColor: Long, + @Assisted private val launchedFromIntent: Boolean, + @Assisted private val imagePath: Path?, + @Assisted private val imageUri: String?, + @Assisted private val maxResolution: Resolution, + private val prefs: Preferences, +) : ViewModelProvider.Factory { + override fun create(modelClass: Class): T { + return EditViewModel( + primaryColor, + launchedFromIntent, + imagePath, + imageUri, + maxResolution, + prefs, + ) as T + } + + @AssistedFactory + interface Factory { + fun create( + @Assisted primaryColor: Long, + @Assisted launchedFromIntent: Boolean, + @Assisted imagePath: Path?, + @Assisted imageUri: String?, + @Assisted maxResolution: Resolution, + ): EditViewModelFactory + } +} + +private fun loadImageWithPath( + context: Context, + image: Path, + editManager: EditManager +) { + initGlideBuilder(context) + .load(image.toFile()) + .loadInto(editManager) +} + +private fun loadImageWithUri( + context: Context, + uri: String, + editManager: EditManager +) { + initGlideBuilder(context) + .load(uri.toUri()) + .loadInto(editManager) +} + +private fun initGlideBuilder(context: Context) = Glide + .with(context) + .asBitmap() + .skipMemoryCache(true) + .diskCacheStrategy(DiskCacheStrategy.NONE) + +private fun RequestBuilder.loadInto( + editManager: EditManager +) { + into(object : CustomTarget() { + override fun onResourceReady( + bitmap: Bitmap, + transition: Transition? + ) { + editManager.apply { + val image = bitmap.asImageBitmap() + backgroundImage.value = image + setOriginalBackgroundImage(image) + scaleToFit() + } + } + + override fun onLoadCleared(placeholder: Drawable?) {} + }) +} + +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 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 +) diff --git a/app/src/main/java/dev/arkbuilders/arkmemo/ui/views/presentation/edit/MoreOptionsPopup.kt b/app/src/main/java/dev/arkbuilders/arkmemo/ui/views/presentation/edit/MoreOptionsPopup.kt new file mode 100644 index 00000000..3a802238 --- /dev/null +++ b/app/src/main/java/dev/arkbuilders/arkmemo/ui/views/presentation/edit/MoreOptionsPopup.kt @@ -0,0 +1,131 @@ +package dev.arkbuilders.arkmemo.ui.views.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.arkmemo.R +import dev.arkbuilders.arkmemo.ui.views.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/app/src/main/java/dev/arkbuilders/arkmemo/ui/views/presentation/edit/NewImageOptionsDialog.kt b/app/src/main/java/dev/arkbuilders/arkmemo/ui/views/presentation/edit/NewImageOptionsDialog.kt new file mode 100644 index 00000000..a023630f --- /dev/null +++ b/app/src/main/java/dev/arkbuilders/arkmemo/ui/views/presentation/edit/NewImageOptionsDialog.kt @@ -0,0 +1,328 @@ +package dev.arkbuilders.arkmemo.ui.views.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.Checkbox +import androidx.compose.material.MaterialTheme +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.arkmemo.R +import dev.arkbuilders.arkmemo.ui.views.data.Resolution +import dev.arkbuilders.arkmemo.ui.views.presentation.drawing.EditManager +import dev.arkbuilders.arkmemo.ui.views.presentation.edit.resize.Hint +import dev.arkbuilders.arkmemo.ui.views.presentation.edit.resize.delayHidingHint +import dev.arkbuilders.arkmemo.ui.views.presentation.theme.Gray + +//import dev.arkbuilders.arkmemo.ui.views.presentation.theme.getGray + +@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 = MaterialTheme.colors.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( + stringResource(R.string.height), + Modifier.fillMaxWidth(), + color = MaterialTheme.colors.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() + } + ) { + Text("Close") + } + TextButton( + 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/app/src/main/java/dev/arkbuilders/arkmemo/ui/views/presentation/edit/Operation.kt b/app/src/main/java/dev/arkbuilders/arkmemo/ui/views/presentation/edit/Operation.kt new file mode 100644 index 00000000..a8e92d88 --- /dev/null +++ b/app/src/main/java/dev/arkbuilders/arkmemo/ui/views/presentation/edit/Operation.kt @@ -0,0 +1,9 @@ +package dev.arkbuilders.arkmemo.ui.views.presentation.edit + +interface Operation { + fun apply() + + fun undo() + + fun redo() +} diff --git a/app/src/main/java/dev/arkbuilders/arkmemo/ui/views/presentation/edit/SavePathDialog.kt b/app/src/main/java/dev/arkbuilders/arkmemo/ui/views/presentation/edit/SavePathDialog.kt new file mode 100644 index 00000000..466ea3ea --- /dev/null +++ b/app/src/main/java/dev/arkbuilders/arkmemo/ui/views/presentation/edit/SavePathDialog.kt @@ -0,0 +1,233 @@ +package dev.arkbuilders.arkmemo.ui.views.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.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.layout.fillMaxSize +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.Button +import androidx.compose.material.Checkbox +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.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.window.Dialog +import androidx.fragment.app.FragmentManager +import java.nio.file.Path +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +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 dev.arkbuilders.arkfilepicker.ArkFilePickerConfig +import dev.arkbuilders.arkfilepicker.presentation.filepicker.ArkFilePickerFragment +import dev.arkbuilders.arkfilepicker.presentation.filepicker.ArkFilePickerMode +import dev.arkbuilders.arkfilepicker.presentation.onArkPathPicked +import dev.arkbuilders.arkmemo.R +import dev.arkbuilders.arkmemo.ui.views.presentation.utils.findNotExistCopyName +import kotlin.io.path.name +import androidx.compose.material.CircularProgressIndicator +import androidx.compose.runtime.key +import androidx.compose.ui.ExperimentalComposeUiApi +import androidx.compose.ui.platform.LocalContext +import dev.arkbuilders.arkmemo.ui.views.presentation.utils.toast +import java.nio.file.Files + +@RequiresApi(Build.VERSION_CODES.UPSIDE_DOWN_CAKE) +@OptIn(ExperimentalComposeUiApi::class) +@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( + 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 + ) { + 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)!!) + } + ) { + 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/app/src/main/java/dev/arkbuilders/arkmemo/ui/views/presentation/edit/TransparencyChessBoard.kt b/app/src/main/java/dev/arkbuilders/arkmemo/ui/views/presentation/edit/TransparencyChessBoard.kt new file mode 100644 index 00000000..92147aab --- /dev/null +++ b/app/src/main/java/dev/arkbuilders/arkmemo/ui/views/presentation/edit/TransparencyChessBoard.kt @@ -0,0 +1,91 @@ +package dev.arkbuilders.arkmemo.ui.views.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.ui.graphics.Canvas +import androidx.compose.foundation.Canvas +import androidx.compose.foundation.background +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.arkmemo.ui.views.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/app/src/main/java/dev/arkbuilders/arkmemo/ui/views/presentation/edit/blur/BlurIntensityPopup.kt b/app/src/main/java/dev/arkbuilders/arkmemo/ui/views/presentation/edit/blur/BlurIntensityPopup.kt new file mode 100644 index 00000000..5e590ce0 --- /dev/null +++ b/app/src/main/java/dev/arkbuilders/arkmemo/ui/views/presentation/edit/blur/BlurIntensityPopup.kt @@ -0,0 +1,56 @@ +package dev.arkbuilders.arkmemo.ui.views.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.arkmemo.R +import dev.arkbuilders.arkmemo.ui.views.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/app/src/main/java/dev/arkbuilders/arkmemo/ui/views/presentation/edit/blur/BlurOperation.kt b/app/src/main/java/dev/arkbuilders/arkmemo/ui/views/presentation/edit/blur/BlurOperation.kt new file mode 100644 index 00000000..2e211d1e --- /dev/null +++ b/app/src/main/java/dev/arkbuilders/arkmemo/ui/views/presentation/edit/blur/BlurOperation.kt @@ -0,0 +1,183 @@ +package dev.arkbuilders.arkmemo.ui.views.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.arkmemo.ui.views.presentation.drawing.EditManager +import dev.arkbuilders.arkmemo.ui.views.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/app/src/main/java/dev/arkbuilders/arkmemo/ui/views/presentation/edit/crop/CropAspectRatiosMenu.kt b/app/src/main/java/dev/arkbuilders/arkmemo/ui/views/presentation/edit/crop/CropAspectRatiosMenu.kt new file mode 100644 index 00000000..d27bcdd5 --- /dev/null +++ b/app/src/main/java/dev/arkbuilders/arkmemo/ui/views/presentation/edit/crop/CropAspectRatiosMenu.kt @@ -0,0 +1,241 @@ +package dev.arkbuilders.arkmemo.ui.views.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.arkmemo.R +import dev.arkbuilders.arkmemo.ui.views.presentation.edit.crop.AspectRatio.aspectRatios +import dev.arkbuilders.arkmemo.ui.views.presentation.edit.crop.AspectRatio.isChanged +import dev.arkbuilders.arkmemo.ui.views.presentation.edit.crop.AspectRatio.isCropFree +import dev.arkbuilders.arkmemo.ui.views.presentation.edit.crop.AspectRatio.isCropSquare +import dev.arkbuilders.arkmemo.ui.views.presentation.edit.crop.AspectRatio.isCrop_9_16 +import dev.arkbuilders.arkmemo.ui.views.presentation.edit.crop.AspectRatio.isCrop_2_3 +import dev.arkbuilders.arkmemo.ui.views.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/app/src/main/java/dev/arkbuilders/arkmemo/ui/views/presentation/edit/crop/CropOperation.kt b/app/src/main/java/dev/arkbuilders/arkmemo/ui/views/presentation/edit/crop/CropOperation.kt new file mode 100644 index 00000000..ebc0fbc0 --- /dev/null +++ b/app/src/main/java/dev/arkbuilders/arkmemo/ui/views/presentation/edit/crop/CropOperation.kt @@ -0,0 +1,53 @@ +package dev.arkbuilders.arkmemo.ui.views.presentation.edit.crop + +import androidx.compose.ui.graphics.asImageBitmap +import dev.arkbuilders.arkmemo.ui.views.presentation.drawing.EditManager +import dev.arkbuilders.arkmemo.ui.views.presentation.edit.Operation +import dev.arkbuilders.arkmemo.ui.views.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/app/src/main/java/dev/arkbuilders/arkmemo/ui/views/presentation/edit/crop/CropWindow.kt b/app/src/main/java/dev/arkbuilders/arkmemo/ui/views/presentation/edit/crop/CropWindow.kt new file mode 100644 index 00000000..7c0e7ba2 --- /dev/null +++ b/app/src/main/java/dev/arkbuilders/arkmemo/ui/views/presentation/edit/crop/CropWindow.kt @@ -0,0 +1,443 @@ +package dev.arkbuilders.arkmemo.ui.views.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.arkmemo.ui.views.presentation.drawing.EditManager +import dev.arkbuilders.arkmemo.ui.views.presentation.edit.crop.AspectRatio.CROP_2_3 +import dev.arkbuilders.arkmemo.ui.views.presentation.edit.crop.AspectRatio.CROP_4_5 +import dev.arkbuilders.arkmemo.ui.views.presentation.edit.crop.AspectRatio.CROP_9_16 +import dev.arkbuilders.arkmemo.ui.views.presentation.edit.crop.AspectRatio.CROP_SQUARE +import dev.arkbuilders.arkmemo.ui.views.presentation.edit.crop.AspectRatio.isCropFree +import dev.arkbuilders.arkmemo.ui.views.presentation.edit.crop.AspectRatio.isCropSquare +import dev.arkbuilders.arkmemo.ui.views.presentation.edit.crop.AspectRatio.isCrop_2_3 +import dev.arkbuilders.arkmemo.ui.views.presentation.edit.crop.AspectRatio.isCrop_4_5 +import dev.arkbuilders.arkmemo.ui.views.presentation.edit.crop.AspectRatio.isCrop_9_16 +import dev.arkbuilders.arkmemo.ui.views.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/app/src/main/java/dev/arkbuilders/arkmemo/ui/views/presentation/edit/draw/DrawOperation.kt b/app/src/main/java/dev/arkbuilders/arkmemo/ui/views/presentation/edit/draw/DrawOperation.kt new file mode 100644 index 00000000..b9e72112 --- /dev/null +++ b/app/src/main/java/dev/arkbuilders/arkmemo/ui/views/presentation/edit/draw/DrawOperation.kt @@ -0,0 +1,37 @@ +package dev.arkbuilders.arkmemo.ui.views.presentation.edit.draw + +import androidx.compose.ui.graphics.Path +import dev.arkbuilders.arkmemo.ui.views.presentation.drawing.EditManager +import dev.arkbuilders.arkmemo.ui.views.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/app/src/main/java/dev/arkbuilders/arkmemo/ui/views/presentation/edit/resize/ResizeInput.kt b/app/src/main/java/dev/arkbuilders/arkmemo/ui/views/presentation/edit/resize/ResizeInput.kt new file mode 100644 index 00000000..6424f8cc --- /dev/null +++ b/app/src/main/java/dev/arkbuilders/arkmemo/ui/views/presentation/edit/resize/ResizeInput.kt @@ -0,0 +1,201 @@ +package dev.arkbuilders.arkmemo.ui.views.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 kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch +import dev.arkbuilders.arkmemo.R +import dev.arkbuilders.arkmemo.ui.views.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/app/src/main/java/dev/arkbuilders/arkmemo/ui/views/presentation/edit/resize/ResizeOperation.kt b/app/src/main/java/dev/arkbuilders/arkmemo/ui/views/presentation/edit/resize/ResizeOperation.kt new file mode 100644 index 00000000..d5727714 --- /dev/null +++ b/app/src/main/java/dev/arkbuilders/arkmemo/ui/views/presentation/edit/resize/ResizeOperation.kt @@ -0,0 +1,114 @@ +package dev.arkbuilders.arkmemo.ui.views.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.arkmemo.ui.views.presentation.drawing.EditManager +import dev.arkbuilders.arkmemo.ui.views.presentation.edit.Operation +import dev.arkbuilders.arkmemo.ui.views.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/app/src/main/java/dev/arkbuilders/arkmemo/ui/views/presentation/edit/rotate/RotateOperation.kt b/app/src/main/java/dev/arkbuilders/arkmemo/ui/views/presentation/edit/rotate/RotateOperation.kt new file mode 100644 index 00000000..3e4c5629 --- /dev/null +++ b/app/src/main/java/dev/arkbuilders/arkmemo/ui/views/presentation/edit/rotate/RotateOperation.kt @@ -0,0 +1,47 @@ +package dev.arkbuilders.arkmemo.ui.views.presentation.edit.rotate + +import android.graphics.Matrix +import dev.arkbuilders.arkmemo.ui.views.presentation.drawing.EditManager +import dev.arkbuilders.arkmemo.ui.views.presentation.edit.Operation +import dev.arkbuilders.arkmemo.ui.views.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/app/src/main/java/dev/arkbuilders/arkmemo/ui/views/presentation/main/EditActivity.kt b/app/src/main/java/dev/arkbuilders/arkmemo/ui/views/presentation/main/EditActivity.kt new file mode 100644 index 00000000..2416fdf7 --- /dev/null +++ b/app/src/main/java/dev/arkbuilders/arkmemo/ui/views/presentation/main/EditActivity.kt @@ -0,0 +1,149 @@ +package dev.arkbuilders.arkmemo.ui.views.presentation.main + +import android.os.Bundle +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.compose.setContent +import androidx.appcompat.app.AppCompatActivity +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import androidx.compose.runtime.getValue +import androidx.compose.runtime.SideEffect +import androidx.compose.ui.platform.LocalContext +import androidx.fragment.app.FragmentManager +import androidx.navigation.NavType +import androidx.navigation.compose.NavHost +import androidx.navigation.compose.composable +import androidx.navigation.compose.rememberNavController +import androidx.navigation.navArgument +import dev.arkbuilders.arkmemo.ui.views.data.Resolution +import dev.arkbuilders.arkmemo.ui.views.presentation.utils.PermissionsHelper +import dev.arkbuilders.arkmemo.ui.views.presentation.edit.EditScreen +import dev.arkbuilders.arkmemo.ui.views.presentation.utils.isWritePermGranted +import dev.arkbuilders.arkmemo.ui.views.presentation.picker.PickerScreen +import dev.arkbuilders.arkmemo.ui.views.presentation.theme.ARKRetouchTheme +import kotlin.io.path.Path + +private const val REAL_PATH_KEY = "real_file_path_2" + +class MainActivity : AppCompatActivity() { + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + + setContent { + ARKRetouchTheme { + MainScreen( + supportFragmentManager, + uri = intent.data?.toString(), + realPath = intent.getStringExtra(REAL_PATH_KEY), + launchedFromIntent = intent.data != null, + ) + } + } + } +} + +@Composable +fun MainScreen( + fragmentManager: FragmentManager, + uri: String?, + realPath: String?, + launchedFromIntent: Boolean = false, +) { + val context = LocalContext.current + val navController = rememberNavController() + var maxResolution by remember { mutableStateOf(Resolution(0, 0)) } + val startScreen = + if ((uri != null || realPath != null) && context.isWritePermGranted()) + NavHelper.editRoute + else + NavHelper.pickerRoute + + val launcher = rememberLauncherForActivityResult( + contract = PermissionsHelper.writePermContract() + ) { isGranted -> + if (!isGranted) return@rememberLauncherForActivityResult + if (launchedFromIntent) { + navController.navigate( + NavHelper.parseEditArgs(realPath, uri, true) + ) + } + } + + SideEffect { + if (!context.isWritePermGranted()) + PermissionsHelper.launchWritePerm(launcher) + } + + NavHost( + navController = navController, + startDestination = startScreen + ) { + composable(NavHelper.pickerRoute) { + PickerScreen( + fragmentManager, + onNavigateToEdit = { path, resolution -> + maxResolution = resolution + navController.navigate( + NavHelper.parseEditArgs( + path?.toString(), + uri = null, + launchedFromIntent = false, + ) + ) + }, + ) + } + composable( + route = NavHelper.editRoute, + arguments = listOf( + navArgument("path") { + type = NavType.StringType + defaultValue = realPath + nullable = true + }, + navArgument("uri") { + type = NavType.StringType + defaultValue = uri + nullable = true + }, + navArgument("launchedFromIntent") { + type = NavType.BoolType + defaultValue = launchedFromIntent + }, + ) + ) { entry -> + EditScreen( + entry.arguments?.getString("path")?.let { Path(it) }, + entry.arguments?.getString("uri"), + fragmentManager, + navigateBack = { navController.popBackStack() }, + entry.arguments?.getBoolean("launchedFromIntent")!!, + maxResolution + ) + } + } +} + +private object NavHelper { + const val editRoute = + "edit?path={path}&uri={uri}&launchedFromIntent={launchedFromIntent}" + + const val pickerRoute = "picker" + + fun parseEditArgs( + path: String?, + uri: String?, + launchedFromIntent: Boolean, + ): String { + val screen = if (path != null) { + "edit?path=$path&launchedFromIntent=$launchedFromIntent" + } else if (uri != null) { + "edit?uri=$uri&launchedFromIntent=$launchedFromIntent" + } else { + "edit" + } + return screen + } +} diff --git a/app/src/main/java/dev/arkbuilders/arkmemo/ui/views/presentation/picker/FilePickerScreen.kt b/app/src/main/java/dev/arkbuilders/arkmemo/ui/views/presentation/picker/FilePickerScreen.kt new file mode 100644 index 00000000..280c3353 --- /dev/null +++ b/app/src/main/java/dev/arkbuilders/arkmemo/ui/views/presentation/picker/FilePickerScreen.kt @@ -0,0 +1,155 @@ +package dev.arkbuilders.arkmemo.ui.views.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.arkfilepicker.ArkFilePickerConfig +import dev.arkbuilders.arkfilepicker.presentation.filepicker.ArkFilePickerFragment +import dev.arkbuilders.arkfilepicker.presentation.filepicker.ArkFilePickerMode +import dev.arkbuilders.arkfilepicker.presentation.onArkPathPicked +import dev.arkbuilders.arkmemo.R +import dev.arkbuilders.arkmemo.ui.views.data.Resolution +import dev.arkbuilders.arkmemo.ui.views.presentation.utils.askWritePermissions +import dev.arkbuilders.arkmemo.ui.views.presentation.utils.isWritePermGranted +import dev.arkbuilders.arkmemo.ui.views.presentation.theme.Purple500 +import dev.arkbuilders.arkmemo.ui.views.presentation.theme.Purple700 +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/app/src/main/java/dev/arkbuilders/arkmemo/ui/views/presentation/theme/Color.kt b/app/src/main/java/dev/arkbuilders/arkmemo/ui/views/presentation/theme/Color.kt new file mode 100644 index 00000000..dd31d07e --- /dev/null +++ b/app/src/main/java/dev/arkbuilders/arkmemo/ui/views/presentation/theme/Color.kt @@ -0,0 +1,9 @@ +package dev.arkbuilders.arkmemo.ui.views.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/app/src/main/java/dev/arkbuilders/arkmemo/ui/views/presentation/theme/Shape.kt b/app/src/main/java/dev/arkbuilders/arkmemo/ui/views/presentation/theme/Shape.kt new file mode 100644 index 00000000..15915d6a --- /dev/null +++ b/app/src/main/java/dev/arkbuilders/arkmemo/ui/views/presentation/theme/Shape.kt @@ -0,0 +1,11 @@ +package dev.arkbuilders.arkmemo.ui.views.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/app/src/main/java/dev/arkbuilders/arkmemo/ui/views/presentation/theme/Theme.kt b/app/src/main/java/dev/arkbuilders/arkmemo/ui/views/presentation/theme/Theme.kt new file mode 100644 index 00000000..cc2cfe5b --- /dev/null +++ b/app/src/main/java/dev/arkbuilders/arkmemo/ui/views/presentation/theme/Theme.kt @@ -0,0 +1,47 @@ +package dev.arkbuilders.arkmemo.ui.views.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/app/src/main/java/dev/arkbuilders/arkmemo/ui/views/presentation/theme/Type.kt b/app/src/main/java/dev/arkbuilders/arkmemo/ui/views/presentation/theme/Type.kt new file mode 100644 index 00000000..e0e77f9c --- /dev/null +++ b/app/src/main/java/dev/arkbuilders/arkmemo/ui/views/presentation/theme/Type.kt @@ -0,0 +1,28 @@ +package dev.arkbuilders.arkmemo.ui.views.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/app/src/main/java/dev/arkbuilders/arkmemo/ui/views/presentation/utils/ImageHelper.kt b/app/src/main/java/dev/arkbuilders/arkmemo/ui/views/presentation/utils/ImageHelper.kt new file mode 100644 index 00000000..cc6d259d --- /dev/null +++ b/app/src/main/java/dev/arkbuilders/arkmemo/ui/views/presentation/utils/ImageHelper.kt @@ -0,0 +1,33 @@ +package dev.arkbuilders.arkmemo.ui.views.presentation.utils + +import android.graphics.Bitmap +import android.graphics.Matrix +import dev.arkbuilders.arkmemo.ui.views.presentation.edit.crop.CropWindow +import dev.arkbuilders.arkmemo.ui.views.presentation.edit.resize.ResizeOperation +import dev.arkbuilders.arkmemo.ui.views.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/app/src/main/java/dev/arkbuilders/arkmemo/ui/views/presentation/utils/PermissionsHelper.kt b/app/src/main/java/dev/arkbuilders/arkmemo/ui/views/presentation/utils/PermissionsHelper.kt new file mode 100644 index 00000000..71da480c --- /dev/null +++ b/app/src/main/java/dev/arkbuilders/arkmemo/ui/views/presentation/utils/PermissionsHelper.kt @@ -0,0 +1,46 @@ +package dev.arkbuilders.arkmemo.ui.views.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 +import dev.arkbuilders.arkmemo.BuildConfig + +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/app/src/main/java/dev/arkbuilders/arkmemo/ui/views/presentation/utils/Utils.kt b/app/src/main/java/dev/arkbuilders/arkmemo/ui/views/presentation/utils/Utils.kt new file mode 100644 index 00000000..738a4544 --- /dev/null +++ b/app/src/main/java/dev/arkbuilders/arkmemo/ui/views/presentation/utils/Utils.kt @@ -0,0 +1,106 @@ +package dev.arkbuilders.arkmemo.ui.views.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 dev.arkbuilders.arkmemo.BuildConfig +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:${BuildConfig.APPLICATION_ID}") + 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/app/src/main/res/drawable/ic_arrow_back.xml b/app/src/main/res/drawable/ic_arrow_back.xml new file mode 100644 index 00000000..6bd5650e --- /dev/null +++ b/app/src/main/res/drawable/ic_arrow_back.xml @@ -0,0 +1,5 @@ + + + diff --git a/app/src/main/res/drawable/ic_aspect_ratio.xml b/app/src/main/res/drawable/ic_aspect_ratio.xml new file mode 100644 index 00000000..77f9080d --- /dev/null +++ b/app/src/main/res/drawable/ic_aspect_ratio.xml @@ -0,0 +1,5 @@ + + + diff --git a/app/src/main/res/drawable/ic_blur_on.xml b/app/src/main/res/drawable/ic_blur_on.xml new file mode 100644 index 00000000..92390c97 --- /dev/null +++ b/app/src/main/res/drawable/ic_blur_on.xml @@ -0,0 +1,5 @@ + + + diff --git a/app/src/main/res/drawable/ic_check.xml b/app/src/main/res/drawable/ic_check.xml new file mode 100644 index 00000000..cf143d4d --- /dev/null +++ b/app/src/main/res/drawable/ic_check.xml @@ -0,0 +1,5 @@ + + + diff --git a/app/src/main/res/drawable/ic_clear.xml b/app/src/main/res/drawable/ic_clear.xml new file mode 100644 index 00000000..844b6b62 --- /dev/null +++ b/app/src/main/res/drawable/ic_clear.xml @@ -0,0 +1,5 @@ + + + diff --git a/app/src/main/res/drawable/ic_crop.xml b/app/src/main/res/drawable/ic_crop.xml new file mode 100644 index 00000000..8e91f26b --- /dev/null +++ b/app/src/main/res/drawable/ic_crop.xml @@ -0,0 +1,5 @@ + + + diff --git a/app/src/main/res/drawable/ic_crop_16_9.xml b/app/src/main/res/drawable/ic_crop_16_9.xml new file mode 100644 index 00000000..d7bdc55d --- /dev/null +++ b/app/src/main/res/drawable/ic_crop_16_9.xml @@ -0,0 +1,5 @@ + + + diff --git a/app/src/main/res/drawable/ic_crop_3_2.xml b/app/src/main/res/drawable/ic_crop_3_2.xml new file mode 100644 index 00000000..466797ff --- /dev/null +++ b/app/src/main/res/drawable/ic_crop_3_2.xml @@ -0,0 +1,5 @@ + + + diff --git a/app/src/main/res/drawable/ic_crop_5_4.xml b/app/src/main/res/drawable/ic_crop_5_4.xml new file mode 100644 index 00000000..89934258 --- /dev/null +++ b/app/src/main/res/drawable/ic_crop_5_4.xml @@ -0,0 +1,5 @@ + + + diff --git a/app/src/main/res/drawable/ic_crop_free.xml b/app/src/main/res/drawable/ic_crop_free.xml new file mode 100644 index 00000000..bca05428 --- /dev/null +++ b/app/src/main/res/drawable/ic_crop_free.xml @@ -0,0 +1,5 @@ + + + diff --git a/app/src/main/res/drawable/ic_crop_square.xml b/app/src/main/res/drawable/ic_crop_square.xml new file mode 100644 index 00000000..7d6b01b5 --- /dev/null +++ b/app/src/main/res/drawable/ic_crop_square.xml @@ -0,0 +1,5 @@ + + + diff --git a/app/src/main/res/drawable/ic_eyedropper.xml b/app/src/main/res/drawable/ic_eyedropper.xml new file mode 100644 index 00000000..e897caa9 --- /dev/null +++ b/app/src/main/res/drawable/ic_eyedropper.xml @@ -0,0 +1,4 @@ + + + diff --git a/app/src/main/res/drawable/ic_insert_photo.xml b/app/src/main/res/drawable/ic_insert_photo.xml new file mode 100644 index 00000000..35960a0b --- /dev/null +++ b/app/src/main/res/drawable/ic_insert_photo.xml @@ -0,0 +1,5 @@ + + + diff --git a/app/src/main/res/drawable/ic_launcher_1_foreground.xml b/app/src/main/res/drawable/ic_launcher_1_foreground.xml new file mode 100644 index 00000000..8ae27aab --- /dev/null +++ b/app/src/main/res/drawable/ic_launcher_1_foreground.xml @@ -0,0 +1,180 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/drawable/ic_launcher_2_foreground.xml b/app/src/main/res/drawable/ic_launcher_2_foreground.xml new file mode 100644 index 00000000..002715bf --- /dev/null +++ b/app/src/main/res/drawable/ic_launcher_2_foreground.xml @@ -0,0 +1,47 @@ + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/drawable/ic_line_weight.xml b/app/src/main/res/drawable/ic_line_weight.xml new file mode 100644 index 00000000..11f94cdd --- /dev/null +++ b/app/src/main/res/drawable/ic_line_weight.xml @@ -0,0 +1,5 @@ + + + diff --git a/app/src/main/res/drawable/ic_more_vert.xml b/app/src/main/res/drawable/ic_more_vert.xml new file mode 100644 index 00000000..39fbab5f --- /dev/null +++ b/app/src/main/res/drawable/ic_more_vert.xml @@ -0,0 +1,5 @@ + + + diff --git a/app/src/main/res/drawable/ic_pan_tool.xml b/app/src/main/res/drawable/ic_pan_tool.xml new file mode 100644 index 00000000..5a9158ff --- /dev/null +++ b/app/src/main/res/drawable/ic_pan_tool.xml @@ -0,0 +1,5 @@ + + + diff --git a/app/src/main/res/drawable/ic_redo.xml b/app/src/main/res/drawable/ic_redo.xml new file mode 100644 index 00000000..b486fdb9 --- /dev/null +++ b/app/src/main/res/drawable/ic_redo.xml @@ -0,0 +1,5 @@ + + + diff --git a/app/src/main/res/drawable/ic_rotate_90_degrees_ccw.xml b/app/src/main/res/drawable/ic_rotate_90_degrees_ccw.xml new file mode 100644 index 00000000..255da67a --- /dev/null +++ b/app/src/main/res/drawable/ic_rotate_90_degrees_ccw.xml @@ -0,0 +1,5 @@ + + + diff --git a/app/src/main/res/drawable/ic_rotate_left.xml b/app/src/main/res/drawable/ic_rotate_left.xml new file mode 100644 index 00000000..b6441960 --- /dev/null +++ b/app/src/main/res/drawable/ic_rotate_left.xml @@ -0,0 +1,5 @@ + + + diff --git a/app/src/main/res/drawable/ic_rotate_right.xml b/app/src/main/res/drawable/ic_rotate_right.xml new file mode 100644 index 00000000..9fa1482b --- /dev/null +++ b/app/src/main/res/drawable/ic_rotate_right.xml @@ -0,0 +1,5 @@ + + + diff --git a/app/src/main/res/drawable/ic_save.xml b/app/src/main/res/drawable/ic_save.xml new file mode 100644 index 00000000..dbc4c4ed --- /dev/null +++ b/app/src/main/res/drawable/ic_save.xml @@ -0,0 +1,5 @@ + + + diff --git a/app/src/main/res/drawable/ic_share.xml b/app/src/main/res/drawable/ic_share.xml new file mode 100644 index 00000000..87cea785 --- /dev/null +++ b/app/src/main/res/drawable/ic_share.xml @@ -0,0 +1,5 @@ + + + diff --git a/app/src/main/res/drawable/ic_undo.xml b/app/src/main/res/drawable/ic_undo.xml new file mode 100644 index 00000000..5fd6184a --- /dev/null +++ b/app/src/main/res/drawable/ic_undo.xml @@ -0,0 +1,5 @@ + + + diff --git a/app/src/main/res/drawable/ic_zoom_in.xml b/app/src/main/res/drawable/ic_zoom_in.xml new file mode 100644 index 00000000..670484fc --- /dev/null +++ b/app/src/main/res/drawable/ic_zoom_in.xml @@ -0,0 +1,6 @@ + + + + diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 14565db5..e72cd375 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -101,5 +101,41 @@ Will be available soon! Invalid recording file Play/Pause + 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 \ No newline at end of file From 28778ea4b15b07ce532f0fd16b336ac60215459f Mon Sep 17 00:00:00 2001 From: Hieu Vu Date: Sat, 5 Oct 2024 15:09:51 +0700 Subject: [PATCH 2/5] Add methods & simulate image saving --- .../ui/fragments/EditGraphicNotesFragment.kt | 32 ++++++++++++++----- .../ui/viewmodels/GraphicNotesViewModel.kt | 22 ++++++++++++- .../ui/views/presentation/edit/EditScreen.kt | 30 +++++++++++------ .../arkbuilders/arkmemo/utils/ControlFlag.kt | 5 +++ 4 files changed, 71 insertions(+), 18 deletions(-) create mode 100644 app/src/main/java/dev/arkbuilders/arkmemo/utils/ControlFlag.kt diff --git a/app/src/main/java/dev/arkbuilders/arkmemo/ui/fragments/EditGraphicNotesFragment.kt b/app/src/main/java/dev/arkbuilders/arkmemo/ui/fragments/EditGraphicNotesFragment.kt index ebe10c27..054d3f0d 100644 --- a/app/src/main/java/dev/arkbuilders/arkmemo/ui/fragments/EditGraphicNotesFragment.kt +++ b/app/src/main/java/dev/arkbuilders/arkmemo/ui/fragments/EditGraphicNotesFragment.kt @@ -11,6 +11,7 @@ import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.ui.Modifier import androidx.compose.ui.platform.ComposeView import androidx.core.view.isVisible +import androidx.fragment.app.activityViewModels import androidx.fragment.app.viewModels import androidx.navigation.findNavController import androidx.recyclerview.widget.LinearLayoutManager @@ -18,6 +19,7 @@ import dagger.hilt.android.AndroidEntryPoint import dev.arkbuilders.arkmemo.R import dev.arkbuilders.arkmemo.models.GraphicNote import dev.arkbuilders.arkmemo.models.Note +import dev.arkbuilders.arkmemo.ui.activities.MainActivity import dev.arkbuilders.arkmemo.ui.adapters.BrushAdapter import dev.arkbuilders.arkmemo.ui.adapters.BrushColor import dev.arkbuilders.arkmemo.ui.adapters.BrushColorBlack @@ -35,6 +37,7 @@ import dev.arkbuilders.arkmemo.ui.adapters.BrushSizeSmall import dev.arkbuilders.arkmemo.ui.adapters.BrushSizeTiny import dev.arkbuilders.arkmemo.ui.adapters.EqualSpacingItemDecoration import dev.arkbuilders.arkmemo.ui.viewmodels.GraphicNotesViewModel +import dev.arkbuilders.arkmemo.ui.viewmodels.NotesViewModel import dev.arkbuilders.arkmemo.ui.views.data.Resolution import dev.arkbuilders.arkmemo.ui.views.presentation.edit.EditScreen import dev.arkbuilders.arkmemo.utils.getBrushSize @@ -46,9 +49,22 @@ import dev.arkbuilders.arkmemo.utils.setDrawableColor import dev.arkbuilders.arkmemo.utils.visible @AndroidEntryPoint -class EditGraphicNotesFragment: BaseFragment() { +class EditGraphicNotesFragment : BaseFragment() { + val notesViewModel: NotesViewModel by activityViewModels() + private val graphicNotesViewModel: GraphicNotesViewModel by viewModels() + private var note = GraphicNote() + val hostActivity by lazy { activity as MainActivity } + override fun onBackPressed() { - TODO("Not yet implemented") + parentFragmentManager.popBackStack() + } + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) +// arguments?.getParcelableCompat(GRAPHICAL_NOTE_KEY, GraphicNote::class.java)?.let { +// note = it +// graphicNotesViewModel.onNoteOpened(note) +// } } override fun onCreateView( @@ -59,12 +75,12 @@ class EditGraphicNotesFragment: BaseFragment() { return ComposeView(requireContext()).apply { setContent { EditScreen( - null, - null, - childFragmentManager, - {onBackPressed()}, - true, - Resolution(120, 120) + imagePath = null, + imageUri = null, + fragmentManager = parentFragmentManager, + navigateBack = { onBackPressed() }, + launchedFromIntent = true, + maxResolution = Resolution(120, 120) ) } } diff --git a/app/src/main/java/dev/arkbuilders/arkmemo/ui/viewmodels/GraphicNotesViewModel.kt b/app/src/main/java/dev/arkbuilders/arkmemo/ui/viewmodels/GraphicNotesViewModel.kt index f1fe3c9d..0773c79b 100644 --- a/app/src/main/java/dev/arkbuilders/arkmemo/ui/viewmodels/GraphicNotesViewModel.kt +++ b/app/src/main/java/dev/arkbuilders/arkmemo/ui/viewmodels/GraphicNotesViewModel.kt @@ -7,20 +7,34 @@ import androidx.lifecycle.MutableLiveData import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import dagger.hilt.android.lifecycle.HiltViewModel +import dev.arkbuilders.arkmemo.di.IO_DISPATCHER import dev.arkbuilders.arkmemo.graphics.Color import dev.arkbuilders.arkmemo.graphics.SVG import dev.arkbuilders.arkmemo.graphics.Size import dev.arkbuilders.arkmemo.models.GraphicNote +import dev.arkbuilders.arkmemo.repo.NotesRepo +import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.launch import javax.inject.Inject +import javax.inject.Named @HiltViewModel -class GraphicNotesViewModel @Inject constructor(): ViewModel() { +class GraphicNotesViewModel @Inject constructor( + @Named(IO_DISPATCHER) private val iODispatcher: CoroutineDispatcher, + private val graphicNotesRepo: NotesRepo, + + ): ViewModel() { + private var paintColor = Color.BLACK.code private var lastPaintColor = paintColor private var strokeWidth = Size.TINY.value + init { + viewModelScope.launch(iODispatcher) { + graphicNotesRepo.init() + } + } val paint get() = Paint().also { it.color = paintColor it.style = Paint.Style.STROKE @@ -44,6 +58,12 @@ class GraphicNotesViewModel @Inject constructor(): ViewModel() { } } + fun onSave(note: GraphicNote) { + viewModelScope.launch { + graphicNotesRepo.save(note) { } + } + } + fun onDrawPath(path: DrawPath) { editPaths.addLast(path) svg.addPath(path) diff --git a/app/src/main/java/dev/arkbuilders/arkmemo/ui/views/presentation/edit/EditScreen.kt b/app/src/main/java/dev/arkbuilders/arkmemo/ui/views/presentation/edit/EditScreen.kt index 737a72b2..c52ffa92 100644 --- a/app/src/main/java/dev/arkbuilders/arkmemo/ui/views/presentation/edit/EditScreen.kt +++ b/app/src/main/java/dev/arkbuilders/arkmemo/ui/views/presentation/edit/EditScreen.kt @@ -78,6 +78,7 @@ import dev.arkbuilders.arkmemo.ui.views.presentation.theme.Gray import dev.arkbuilders.arkmemo.ui.views.presentation.utils.askWritePermissions import dev.arkbuilders.arkmemo.ui.views.presentation.utils.getActivity import dev.arkbuilders.arkmemo.ui.views.presentation.utils.isWritePermGranted +import dev.arkbuilders.arkmemo.utils.ControlFlag.isChangedForMemoIntegration import java.nio.file.Path @Composable @@ -209,6 +210,7 @@ fun EditScreen( } } +@RequiresApi(Build.VERSION_CODES.UPSIDE_DOWN_CAKE) @Composable private fun Menus( imagePath: Path?, @@ -335,7 +337,9 @@ private fun DrawContainer( return@onSizeChanged } - isZoomMode.value -> { return@onSizeChanged } + isZoomMode.value -> { + return@onSizeChanged + } else -> { scaleToFit() @@ -443,12 +447,16 @@ private fun BoxScope.TopMenu( if ( !viewModel.editManager.canUndo.value ) { - if (launchedFromIntent) { - context - .getActivity() - ?.finish() - } else { + if (isChangedForMemoIntegration) { navigateBack() + } else { + if (launchedFromIntent) { + context + .getActivity() + ?.finish() + } else { + navigateBack() + } } } else { viewModel.showExitDialog = true @@ -1049,10 +1057,14 @@ private fun ExitDialog( TextButton( onClick = { viewModel.showExitDialog = false - if (launchedFromIntent) { - context.getActivity()?.finish() - } else { + if (isChangedForMemoIntegration) { navigateBack() + } else { + if (launchedFromIntent) { + context.getActivity()?.finish() + } else { + navigateBack() + } } } ) { diff --git a/app/src/main/java/dev/arkbuilders/arkmemo/utils/ControlFlag.kt b/app/src/main/java/dev/arkbuilders/arkmemo/utils/ControlFlag.kt new file mode 100644 index 00000000..1e269f8f --- /dev/null +++ b/app/src/main/java/dev/arkbuilders/arkmemo/utils/ControlFlag.kt @@ -0,0 +1,5 @@ +package dev.arkbuilders.arkmemo.utils + +object ControlFlag { + val isChangedForMemoIntegration = true +} \ No newline at end of file From 4c3c25cd9e6b36b89d6bac8777ef529b8311f8cd Mon Sep 17 00:00:00 2001 From: Hieu Vu Date: Sat, 12 Oct 2024 14:53:43 +0700 Subject: [PATCH 3/5] Open mock data --- .../arkbuilders/arkmemo/models/GraphicNote.kt | 3 ++- .../arkmemo/repo/graphics/GraphicNotesRepo.kt | 2 +- .../ui/fragments/EditGraphicNotesFragment.kt | 27 ++++++++++++++----- .../ui/views/presentation/edit/EditScreen.kt | 10 +++++-- .../views/presentation/edit/EditViewModel.kt | 1 - .../views/presentation/main/EditActivity.kt | 3 ++- 6 files changed, 34 insertions(+), 12 deletions(-) diff --git a/app/src/main/java/dev/arkbuilders/arkmemo/models/GraphicNote.kt b/app/src/main/java/dev/arkbuilders/arkmemo/models/GraphicNote.kt index 5ab1dc85..9231faa3 100644 --- a/app/src/main/java/dev/arkbuilders/arkmemo/models/GraphicNote.kt +++ b/app/src/main/java/dev/arkbuilders/arkmemo/models/GraphicNote.kt @@ -14,5 +14,6 @@ data class GraphicNote( val svg: SVG? = null, @IgnoredOnParcel override var resource: Resource? = null, - override var pendingForDelete: Boolean = false + override var pendingForDelete: Boolean = false, + val drawPath : String = "/storage/emulated/0/Documents/.ark/user/properties/3841-96509498.svg" ) : Note, Parcelable \ No newline at end of file diff --git a/app/src/main/java/dev/arkbuilders/arkmemo/repo/graphics/GraphicNotesRepo.kt b/app/src/main/java/dev/arkbuilders/arkmemo/repo/graphics/GraphicNotesRepo.kt index f820df82..8f08d0e1 100644 --- a/app/src/main/java/dev/arkbuilders/arkmemo/repo/graphics/GraphicNotesRepo.kt +++ b/app/src/main/java/dev/arkbuilders/arkmemo/repo/graphics/GraphicNotesRepo.kt @@ -104,7 +104,7 @@ class GraphicNotesRepo @Inject constructor( title = userNoteProperties.title, description = userNoteProperties.description, svg = svg, - resource = resource + resource = resource, ) } } diff --git a/app/src/main/java/dev/arkbuilders/arkmemo/ui/fragments/EditGraphicNotesFragment.kt b/app/src/main/java/dev/arkbuilders/arkmemo/ui/fragments/EditGraphicNotesFragment.kt index 054d3f0d..793ddd67 100644 --- a/app/src/main/java/dev/arkbuilders/arkmemo/ui/fragments/EditGraphicNotesFragment.kt +++ b/app/src/main/java/dev/arkbuilders/arkmemo/ui/fragments/EditGraphicNotesFragment.kt @@ -17,6 +17,7 @@ import androidx.navigation.findNavController import androidx.recyclerview.widget.LinearLayoutManager import dagger.hilt.android.AndroidEntryPoint import dev.arkbuilders.arkmemo.R +import dev.arkbuilders.arkmemo.graphics.SVG import dev.arkbuilders.arkmemo.models.GraphicNote import dev.arkbuilders.arkmemo.models.Note import dev.arkbuilders.arkmemo.ui.activities.MainActivity @@ -47,6 +48,11 @@ import dev.arkbuilders.arkmemo.utils.gone import dev.arkbuilders.arkmemo.utils.observeSaveResult import dev.arkbuilders.arkmemo.utils.setDrawableColor import dev.arkbuilders.arkmemo.utils.visible +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import java.nio.file.Paths +import kotlin.io.path.Path @AndroidEntryPoint class EditGraphicNotesFragment : BaseFragment() { @@ -61,26 +67,34 @@ class EditGraphicNotesFragment : BaseFragment() { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) -// arguments?.getParcelableCompat(GRAPHICAL_NOTE_KEY, GraphicNote::class.java)?.let { -// note = it -// graphicNotesViewModel.onNoteOpened(note) -// } + arguments?.getParcelableCompat(GRAPHICAL_NOTE_KEY, GraphicNote::class.java)?.let { + note = it + graphicNotesViewModel.onNoteOpened(note) + } } + val coroutineScope = CoroutineScope(Dispatchers.IO) override fun onCreateView( inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle? ): View? { + coroutineScope.launch { + + val svgpaths = SVG.parse(Path("/storage/emulated/0/Documents/.ark/user/properties/3841-9650949")) + svgpaths.getPaths() + } return ComposeView(requireContext()).apply { + setContent { EditScreen( - imagePath = null, + imagePath = Path(note.drawPath), imageUri = null, fragmentManager = parentFragmentManager, navigateBack = { onBackPressed() }, launchedFromIntent = true, - maxResolution = Resolution(120, 120) + maxResolution = Resolution(120, 120), + onSaveSvg = { graphicNotesViewModel.onSave(note) } ) } } @@ -89,6 +103,7 @@ class EditGraphicNotesFragment : BaseFragment() { companion object { const val TAG = "EditGraphicNotesFragment" private const val GRAPHICAL_NOTE_KEY = "graphical note" + const val PATH = "image_path" fun newInstance() = EditGraphicNotesFragment() diff --git a/app/src/main/java/dev/arkbuilders/arkmemo/ui/views/presentation/edit/EditScreen.kt b/app/src/main/java/dev/arkbuilders/arkmemo/ui/views/presentation/edit/EditScreen.kt index c52ffa92..87ac5337 100644 --- a/app/src/main/java/dev/arkbuilders/arkmemo/ui/views/presentation/edit/EditScreen.kt +++ b/app/src/main/java/dev/arkbuilders/arkmemo/ui/views/presentation/edit/EditScreen.kt @@ -88,7 +88,8 @@ fun EditScreen( fragmentManager: FragmentManager, navigateBack: () -> Unit, launchedFromIntent: Boolean, - maxResolution: Resolution + maxResolution: Resolution, + onSaveSvg: () -> Unit ) { val primaryColor = MaterialTheme.colors.primary.value.toLong() val viewModel: EditViewModel = @@ -137,6 +138,7 @@ fun EditScreen( viewModel.isLoaded = false }, launchedFromIntent = launchedFromIntent, + onSaveSvg = onSaveSvg ) BackHandler { @@ -1026,7 +1028,8 @@ private fun HandleImageSavedEffect( private fun ExitDialog( viewModel: EditViewModel, navigateBack: () -> Unit, - launchedFromIntent: Boolean + launchedFromIntent: Boolean, + onSaveSvg: () -> Unit = {} ) { if (!viewModel.showExitDialog) return @@ -1046,6 +1049,9 @@ private fun ExitDialog( confirmButton = { Button( onClick = { + if (isChangedForMemoIntegration) { + onSaveSvg() + } viewModel.showExitDialog = false viewModel.showSavePathDialog = true } diff --git a/app/src/main/java/dev/arkbuilders/arkmemo/ui/views/presentation/edit/EditViewModel.kt b/app/src/main/java/dev/arkbuilders/arkmemo/ui/views/presentation/edit/EditViewModel.kt index 63edb036..eb29cf81 100644 --- a/app/src/main/java/dev/arkbuilders/arkmemo/ui/views/presentation/edit/EditViewModel.kt +++ b/app/src/main/java/dev/arkbuilders/arkmemo/ui/views/presentation/edit/EditViewModel.kt @@ -131,7 +131,6 @@ class EditViewModel( viewModelScope.launch(Dispatchers.IO) { isSavingImage = true val combinedBitmap = getEditedImage() - path.outputStream().use { out -> combinedBitmap.asAndroidBitmap() .compress(Bitmap.CompressFormat.PNG, 100, out) diff --git a/app/src/main/java/dev/arkbuilders/arkmemo/ui/views/presentation/main/EditActivity.kt b/app/src/main/java/dev/arkbuilders/arkmemo/ui/views/presentation/main/EditActivity.kt index 2416fdf7..211942ec 100644 --- a/app/src/main/java/dev/arkbuilders/arkmemo/ui/views/presentation/main/EditActivity.kt +++ b/app/src/main/java/dev/arkbuilders/arkmemo/ui/views/presentation/main/EditActivity.kt @@ -120,7 +120,8 @@ fun MainScreen( fragmentManager, navigateBack = { navController.popBackStack() }, entry.arguments?.getBoolean("launchedFromIntent")!!, - maxResolution + maxResolution, + {} ) } } From 5304a8e15d5bfa7e5f30536dd6d40cd21a1aaa40 Mon Sep 17 00:00:00 2001 From: Hieu Vu Date: Thu, 24 Oct 2024 21:59:27 +0700 Subject: [PATCH 4/5] Load svg into canvas --- .../ui/fragments/EditGraphicNotesFragment.kt | 18 ++++++++++++---- .../ui/views/presentation/edit/EditScreen.kt | 6 ++++-- .../views/presentation/edit/EditViewModel.kt | 21 +++++++++++++++++++ 3 files changed, 39 insertions(+), 6 deletions(-) diff --git a/app/src/main/java/dev/arkbuilders/arkmemo/ui/fragments/EditGraphicNotesFragment.kt b/app/src/main/java/dev/arkbuilders/arkmemo/ui/fragments/EditGraphicNotesFragment.kt index 793ddd67..e8d9fe5e 100644 --- a/app/src/main/java/dev/arkbuilders/arkmemo/ui/fragments/EditGraphicNotesFragment.kt +++ b/app/src/main/java/dev/arkbuilders/arkmemo/ui/fragments/EditGraphicNotesFragment.kt @@ -9,6 +9,9 @@ import android.view.View import android.view.ViewGroup import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.Paint +import androidx.compose.ui.graphics.asComposePath import androidx.compose.ui.platform.ComposeView import androidx.core.view.isVisible import androidx.fragment.app.activityViewModels @@ -37,6 +40,7 @@ import dev.arkbuilders.arkmemo.ui.adapters.BrushSizeMedium import dev.arkbuilders.arkmemo.ui.adapters.BrushSizeSmall import dev.arkbuilders.arkmemo.ui.adapters.BrushSizeTiny import dev.arkbuilders.arkmemo.ui.adapters.EqualSpacingItemDecoration +import dev.arkbuilders.arkmemo.ui.viewmodels.DrawPath import dev.arkbuilders.arkmemo.ui.viewmodels.GraphicNotesViewModel import dev.arkbuilders.arkmemo.ui.viewmodels.NotesViewModel import dev.arkbuilders.arkmemo.ui.views.data.Resolution @@ -53,7 +57,7 @@ import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch import java.nio.file.Paths import kotlin.io.path.Path - +import dev.arkbuilders.arkmemo.ui.views.presentation.drawing.DrawPath as DrawPathCompose @AndroidEntryPoint class EditGraphicNotesFragment : BaseFragment() { val notesViewModel: NotesViewModel by activityViewModels() @@ -80,9 +84,15 @@ class EditGraphicNotesFragment : BaseFragment() { savedInstanceState: Bundle? ): View? { coroutineScope.launch { - - val svgpaths = SVG.parse(Path("/storage/emulated/0/Documents/.ark/user/properties/3841-9650949")) - svgpaths.getPaths() + val svgpaths = SVG.parse(Path("/storage/emulated/0/Documents/32254-1096105931.svg")) + svgpaths.getPaths().map { + DrawPathCompose( + path = it.path.asComposePath(), + paint = Paint().apply { + color = Color(it.paint.color) + } + ) + } } return ComposeView(requireContext()).apply { diff --git a/app/src/main/java/dev/arkbuilders/arkmemo/ui/views/presentation/edit/EditScreen.kt b/app/src/main/java/dev/arkbuilders/arkmemo/ui/views/presentation/edit/EditScreen.kt index 87ac5337..a424b9ba 100644 --- a/app/src/main/java/dev/arkbuilders/arkmemo/ui/views/presentation/edit/EditScreen.kt +++ b/app/src/main/java/dev/arkbuilders/arkmemo/ui/views/presentation/edit/EditScreen.kt @@ -73,6 +73,7 @@ import dev.arkbuilders.arkmemo.ui.views.presentation.edit.resize.Hint import dev.arkbuilders.arkmemo.ui.views.presentation.edit.resize.ResizeInput import dev.arkbuilders.arkmemo.ui.views.presentation.edit.resize.delayHidingHint import dev.arkbuilders.arkmemo.di.DIManager +import dev.arkbuilders.arkmemo.ui.views.presentation.drawing.DrawPath import dev.arkbuilders.arkmemo.ui.views.presentation.picker.toPx import dev.arkbuilders.arkmemo.ui.views.presentation.theme.Gray import dev.arkbuilders.arkmemo.ui.views.presentation.utils.askWritePermissions @@ -89,11 +90,11 @@ fun EditScreen( navigateBack: () -> Unit, launchedFromIntent: Boolean, maxResolution: Resolution, - onSaveSvg: () -> Unit + onSaveSvg: () -> Unit, ) { val primaryColor = MaterialTheme.colors.primary.value.toLong() val viewModel: EditViewModel = - viewModel( + viewModel( factory = DIManager .component .editVMFactory() @@ -105,6 +106,7 @@ fun EditScreen( maxResolution ) ) + val context = LocalContext.current val showDefaultsDialog = remember { mutableStateOf( diff --git a/app/src/main/java/dev/arkbuilders/arkmemo/ui/views/presentation/edit/EditViewModel.kt b/app/src/main/java/dev/arkbuilders/arkmemo/ui/views/presentation/edit/EditViewModel.kt index eb29cf81..d9b6be67 100644 --- a/app/src/main/java/dev/arkbuilders/arkmemo/ui/views/presentation/edit/EditViewModel.kt +++ b/app/src/main/java/dev/arkbuilders/arkmemo/ui/views/presentation/edit/EditViewModel.kt @@ -19,6 +19,7 @@ import androidx.compose.ui.graphics.ImageBitmapConfig import androidx.compose.ui.graphics.Canvas import androidx.compose.ui.graphics.Paint import androidx.compose.ui.graphics.asAndroidBitmap +import androidx.compose.ui.graphics.asComposePath import androidx.compose.ui.graphics.nativeCanvas import androidx.compose.ui.graphics.asImageBitmap import androidx.compose.ui.unit.IntSize @@ -43,11 +44,14 @@ import dev.arkbuilders.arkmemo.R import dev.arkbuilders.arkmemo.ui.views.data.Preferences import dev.arkbuilders.arkmemo.ui.views.data.Resolution import dev.arkbuilders.arkmemo.di.DIManager +import dev.arkbuilders.arkmemo.graphics.SVG +import dev.arkbuilders.arkmemo.ui.views.presentation.drawing.DrawPath import dev.arkbuilders.arkmemo.ui.views.presentation.drawing.EditManager import dev.arkbuilders.arkmemo.ui.views.presentation.edit.resize.ResizeOperation import timber.log.Timber import java.io.File import java.nio.file.Path +import kotlin.io.path.Path import kotlin.io.path.outputStream import kotlin.system.measureTimeMillis @@ -81,6 +85,22 @@ class EditViewModel( private val _usedColors = mutableListOf() val usedColors: List = _usedColors + fun setPaths() { + viewModelScope.launch { + editManager.setPaintColor(Color.Blue) + val svgpaths = SVG.parse(Path("/storage/emulated/0/Documents/32254-1096105931.svg")) + svgpaths.getPaths().forEach { + val draw = DrawPath( + path = it.path.asComposePath(), + paint = Paint().apply { + color = Color(it.paint.color) + } + ) + editManager.addDrawPath(draw.path) + editManager.setPaintColor(draw.paint.color) + } + } + } init { if (imageUri == null && imagePath == null) { viewModelScope.launch { @@ -104,6 +124,7 @@ class EditViewModel( editManager.setPaintColor(color) } + setPaths() } fun loadImage() { From 5f6d9433ee6401d9e078c689b06793bdb53ae5ce Mon Sep 17 00:00:00 2001 From: Hieu Vu Date: Sat, 2 Nov 2024 21:59:30 +0700 Subject: [PATCH 5/5] Create classes for canvas loader --- .../resourceloader/BitmapResourceLoader.kt | 61 +++++++++++++++++++ .../resourceloader/CanvasResourceLoader.kt | 9 +++ .../views/resourceloader/SvgResourceLoader.kt | 14 +++++ 3 files changed, 84 insertions(+) create mode 100644 app/src/main/java/dev/arkbuilders/arkmemo/ui/views/resourceloader/BitmapResourceLoader.kt create mode 100644 app/src/main/java/dev/arkbuilders/arkmemo/ui/views/resourceloader/CanvasResourceLoader.kt create mode 100644 app/src/main/java/dev/arkbuilders/arkmemo/ui/views/resourceloader/SvgResourceLoader.kt diff --git a/app/src/main/java/dev/arkbuilders/arkmemo/ui/views/resourceloader/BitmapResourceLoader.kt b/app/src/main/java/dev/arkbuilders/arkmemo/ui/views/resourceloader/BitmapResourceLoader.kt new file mode 100644 index 00000000..6a8e2465 --- /dev/null +++ b/app/src/main/java/dev/arkbuilders/arkmemo/ui/views/resourceloader/BitmapResourceLoader.kt @@ -0,0 +1,61 @@ +package dev.arkbuilders.arkmemo.ui.views.resourceloader + +import android.content.Context +import android.graphics.Bitmap +import android.graphics.drawable.Drawable +import androidx.compose.ui.graphics.ImageBitmap +import androidx.compose.ui.graphics.asImageBitmap +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.arkmemo.di.DIManager +import dev.arkbuilders.arkmemo.ui.views.presentation.drawing.EditManager +import java.nio.file.Path + +class BitmapResourceLoader( + val context: Context = DIManager.component.app(), + val editManager: EditManager +) : CanvasResourceLoader { + + private val glideBuilder = Glide + .with(context) + .asBitmap() + .skipMemoryCache(true) + .diskCacheStrategy(DiskCacheStrategy.NONE) + + private lateinit var bitMapResource: ImageBitmap + override suspend fun loadResourceInto(path: Path, editManager: EditManager) { + loadImage(path) + } + override suspend fun getResource() { + + } + + private fun loadImage( + resourcePath: Path, + ) { + glideBuilder + .load(resourcePath.toFile()) + .loadInto() + } + + + 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/app/src/main/java/dev/arkbuilders/arkmemo/ui/views/resourceloader/CanvasResourceLoader.kt b/app/src/main/java/dev/arkbuilders/arkmemo/ui/views/resourceloader/CanvasResourceLoader.kt new file mode 100644 index 00000000..5aac53c5 --- /dev/null +++ b/app/src/main/java/dev/arkbuilders/arkmemo/ui/views/resourceloader/CanvasResourceLoader.kt @@ -0,0 +1,9 @@ +package dev.arkbuilders.arkmemo.ui.views.resourceloader + +import dev.arkbuilders.arkmemo.ui.views.presentation.drawing.EditManager +import java.nio.file.Path + +interface CanvasResourceLoader { + suspend fun loadResourceInto(path: Path, editManager: EditManager) + suspend fun getResource() +} \ No newline at end of file diff --git a/app/src/main/java/dev/arkbuilders/arkmemo/ui/views/resourceloader/SvgResourceLoader.kt b/app/src/main/java/dev/arkbuilders/arkmemo/ui/views/resourceloader/SvgResourceLoader.kt new file mode 100644 index 00000000..4f18988d --- /dev/null +++ b/app/src/main/java/dev/arkbuilders/arkmemo/ui/views/resourceloader/SvgResourceLoader.kt @@ -0,0 +1,14 @@ +package dev.arkbuilders.arkmemo.ui.views.resourceloader + +import dev.arkbuilders.arkmemo.ui.views.presentation.drawing.EditManager +import java.nio.file.Path + +class SvgResourceLoader: CanvasResourceLoader { + + override suspend fun loadResourceInto(path: Path, editManager: EditManager) { + + } + override suspend fun getResource() { + + } +} \ No newline at end of file