New feature: allow user to rotate a page
This commit is contained in:
@@ -18,7 +18,7 @@ import java.io.File
|
|||||||
|
|
||||||
const val SCAN_DIR_NAME = "scanned_pages"
|
const val SCAN_DIR_NAME = "scanned_pages"
|
||||||
|
|
||||||
class ImageRepository(appFilesDir: File) {
|
class ImageRepository(appFilesDir: File, val transformations: ImageTransformations) {
|
||||||
|
|
||||||
private val scanDir: File = File(appFilesDir, SCAN_DIR_NAME).apply {
|
private val scanDir: File = File(appFilesDir, SCAN_DIR_NAME).apply {
|
||||||
if (!exists()) mkdirs()
|
if (!exists()) mkdirs()
|
||||||
@@ -39,6 +39,30 @@ class ImageRepository(appFilesDir: File) {
|
|||||||
fileNames.add(fileName)
|
fileNames.add(fileName)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
val idRegex = Regex("([0-9]+)(-(90|180|270))?\\.jpg")
|
||||||
|
|
||||||
|
fun rotate(id: String, clockwise: Boolean) {
|
||||||
|
val originalFile = File(scanDir, id)
|
||||||
|
if (!originalFile.exists()) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
idRegex.matchEntire(id)?.let {
|
||||||
|
val baseId = it.groupValues[1]
|
||||||
|
val degrees = it.groupValues[3].ifEmpty { "0" }.toInt()
|
||||||
|
val targetDegrees = (degrees + (if (clockwise) 90 else 270)) % 360
|
||||||
|
val rotatedId = if (targetDegrees == 0) "$baseId.jpg" else "$baseId-$targetDegrees.jpg"
|
||||||
|
val rotatedFile = File(scanDir, rotatedId)
|
||||||
|
transformations.rotate(originalFile, rotatedFile, clockwise)
|
||||||
|
if (rotatedFile.exists()) {
|
||||||
|
val index = fileNames.indexOf(id)
|
||||||
|
if (index >= 0) {
|
||||||
|
fileNames[index] = rotatedId
|
||||||
|
}
|
||||||
|
delete(id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fun getContent(id: String): ByteArray? {
|
fun getContent(id: String): ByteArray? {
|
||||||
if (fileNames.contains(id)) {
|
if (fileNames.contains(id)) {
|
||||||
val file = File(scanDir, id)
|
val file = File(scanDir, id)
|
||||||
|
|||||||
23
app/src/main/java/org/fairscan/app/ImageTransformations.kt
Normal file
23
app/src/main/java/org/fairscan/app/ImageTransformations.kt
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
/*
|
||||||
|
* Copyright 2025 Pierre-Yves Nicolas
|
||||||
|
*
|
||||||
|
* This program is free software: you can redistribute it and/or modify it
|
||||||
|
* under the terms of the GNU General Public License as published by the Free
|
||||||
|
* Software Foundation, either version 3 of the License, or (at your option)
|
||||||
|
* any later version.
|
||||||
|
* This program is distributed in the hope that it will be useful, but WITHOUT
|
||||||
|
* ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
|
||||||
|
* FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
|
||||||
|
* more details.
|
||||||
|
* You should have received a copy of the GNU General Public License along with
|
||||||
|
* this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
|
*/
|
||||||
|
package org.fairscan.app
|
||||||
|
|
||||||
|
import java.io.File
|
||||||
|
|
||||||
|
fun interface ImageTransformations {
|
||||||
|
|
||||||
|
fun rotate(inputFile: File, outputFile: File, clockwise: Boolean)
|
||||||
|
|
||||||
|
}
|
||||||
@@ -110,7 +110,8 @@ class MainActivity : ComponentActivity() {
|
|||||||
onStartNew = {
|
onStartNew = {
|
||||||
viewModel.startNewDocument()
|
viewModel.startNewDocument()
|
||||||
viewModel.navigateTo(Screen.Main.Home) },
|
viewModel.navigateTo(Screen.Main.Home) },
|
||||||
onDeleteImage = { id -> viewModel.deletePage(id) }
|
onDeleteImage = { id -> viewModel.deletePage(id) },
|
||||||
|
onRotateImage = { id, clockwise -> viewModel.rotateImage(id, clockwise) }
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
is Screen.Overlay.About -> {
|
is Screen.Overlay.About -> {
|
||||||
|
|||||||
@@ -58,7 +58,7 @@ class MainViewModel(
|
|||||||
override fun <T : ViewModel> create(modelClass: Class<T>, extras: CreationExtras): T {
|
override fun <T : ViewModel> create(modelClass: Class<T>, extras: CreationExtras): T {
|
||||||
return MainViewModel(
|
return MainViewModel(
|
||||||
ImageSegmentationService(context),
|
ImageSegmentationService(context),
|
||||||
ImageRepository(context.filesDir),
|
ImageRepository(context.filesDir, OpenCvTransformations()),
|
||||||
PdfFileManager(
|
PdfFileManager(
|
||||||
File(context.cacheDir, "pdfs"),
|
File(context.cacheDir, "pdfs"),
|
||||||
Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOWNLOADS),
|
Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOWNLOADS),
|
||||||
@@ -221,6 +221,13 @@ class MainViewModel(
|
|||||||
_captureState.value = CaptureState.Idle
|
_captureState.value = CaptureState.Idle
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun rotateImage(id: String, clockwise: Boolean) {
|
||||||
|
viewModelScope.launch {
|
||||||
|
imageRepository.rotate(id, clockwise)
|
||||||
|
_pageIds.value = imageRepository.imageIds()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fun afterCaptureError() {
|
fun afterCaptureError() {
|
||||||
_captureState.value = CaptureState.Idle
|
_captureState.value = CaptureState.Idle
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,40 @@
|
|||||||
|
/*
|
||||||
|
* Copyright 2025 Pierre-Yves Nicolas
|
||||||
|
*
|
||||||
|
* This program is free software: you can redistribute it and/or modify it
|
||||||
|
* under the terms of the GNU General Public License as published by the Free
|
||||||
|
* Software Foundation, either version 3 of the License, or (at your option)
|
||||||
|
* any later version.
|
||||||
|
* This program is distributed in the hope that it will be useful, but WITHOUT
|
||||||
|
* ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
|
||||||
|
* FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
|
||||||
|
* more details.
|
||||||
|
* You should have received a copy of the GNU General Public License along with
|
||||||
|
* this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
|
*/
|
||||||
|
package org.fairscan.app
|
||||||
|
|
||||||
|
import org.opencv.core.Core
|
||||||
|
import org.opencv.core.Mat
|
||||||
|
import org.opencv.imgcodecs.Imgcodecs
|
||||||
|
import java.io.File
|
||||||
|
|
||||||
|
class OpenCvTransformations : ImageTransformations {
|
||||||
|
override fun rotate(inputFile: File, outputFile: File, clockwise: Boolean) {
|
||||||
|
val src: Mat = Imgcodecs.imread(inputFile.absolutePath)
|
||||||
|
|
||||||
|
require (!src.empty()) { "Could not load image from ${inputFile.absolutePath}" }
|
||||||
|
|
||||||
|
val dst = Mat()
|
||||||
|
Core.rotate(src, dst,
|
||||||
|
if (clockwise) Core.ROTATE_90_CLOCKWISE else Core.ROTATE_90_COUNTERCLOCKWISE
|
||||||
|
)
|
||||||
|
|
||||||
|
if (!Imgcodecs.imwrite(outputFile.absolutePath, dst)) {
|
||||||
|
throw RuntimeException("Could not write image to ${outputFile.absolutePath}")
|
||||||
|
}
|
||||||
|
|
||||||
|
src.release()
|
||||||
|
dst.release()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -29,6 +29,8 @@ import androidx.compose.foundation.layout.width
|
|||||||
import androidx.compose.foundation.lazy.rememberLazyListState
|
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
import androidx.compose.material.icons.Icons
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.automirrored.filled.RotateLeft
|
||||||
|
import androidx.compose.material.icons.automirrored.filled.RotateRight
|
||||||
import androidx.compose.material.icons.filled.Add
|
import androidx.compose.material.icons.filled.Add
|
||||||
import androidx.compose.material.icons.filled.Close
|
import androidx.compose.material.icons.filled.Close
|
||||||
import androidx.compose.material.icons.filled.PictureAsPdf
|
import androidx.compose.material.icons.filled.PictureAsPdf
|
||||||
@@ -72,6 +74,7 @@ fun DocumentScreen(
|
|||||||
pdfActions: PdfGenerationActions,
|
pdfActions: PdfGenerationActions,
|
||||||
onStartNew: () -> Unit,
|
onStartNew: () -> Unit,
|
||||||
onDeleteImage: (String) -> Unit,
|
onDeleteImage: (String) -> Unit,
|
||||||
|
onRotateImage: (String, Boolean) -> Unit,
|
||||||
) {
|
) {
|
||||||
// TODO Check how often images are loaded
|
// TODO Check how often images are loaded
|
||||||
val showNewDocDialog = rememberSaveable { mutableStateOf(false) }
|
val showNewDocDialog = rememberSaveable { mutableStateOf(false) }
|
||||||
@@ -112,7 +115,12 @@ fun DocumentScreen(
|
|||||||
)
|
)
|
||||||
},
|
},
|
||||||
) { modifier ->
|
) { modifier ->
|
||||||
DocumentPreview(document, currentPageIndex, { showDeletePageDialog.value = true }, modifier)
|
DocumentPreview(
|
||||||
|
document,
|
||||||
|
currentPageIndex,
|
||||||
|
{ showDeletePageDialog.value = true },
|
||||||
|
onRotateImage,
|
||||||
|
modifier)
|
||||||
if (showNewDocDialog.value) {
|
if (showNewDocDialog.value) {
|
||||||
NewDocumentDialog(onConfirm = onStartNew, showNewDocDialog, stringResource(R.string.close_document))
|
NewDocumentDialog(onConfirm = onStartNew, showNewDocDialog, stringResource(R.string.close_document))
|
||||||
}
|
}
|
||||||
@@ -137,6 +145,7 @@ private fun DocumentPreview(
|
|||||||
document: DocumentUiModel,
|
document: DocumentUiModel,
|
||||||
currentPageIndex: MutableIntState,
|
currentPageIndex: MutableIntState,
|
||||||
onDeleteImage: (String) -> Unit,
|
onDeleteImage: (String) -> Unit,
|
||||||
|
onRotateImage: (String, Boolean) -> Unit,
|
||||||
modifier: Modifier,
|
modifier: Modifier,
|
||||||
) {
|
) {
|
||||||
val imageId = document.pageId(currentPageIndex.intValue)
|
val imageId = document.pageId(currentPageIndex.intValue)
|
||||||
@@ -170,6 +179,7 @@ private fun DocumentPreview(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
RotationButtons(imageId, onRotateImage, Modifier.align(Alignment.BottomCenter))
|
||||||
SecondaryActionButton(
|
SecondaryActionButton(
|
||||||
Icons.Outlined.Delete,
|
Icons.Outlined.Delete,
|
||||||
contentDescription = stringResource(R.string.delete_page),
|
contentDescription = stringResource(R.string.delete_page),
|
||||||
@@ -193,6 +203,27 @@ private fun DocumentPreview(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun RotationButtons(
|
||||||
|
imageId: String,
|
||||||
|
onRotateImage: (String, Boolean) -> Unit,
|
||||||
|
modifier: Modifier = Modifier
|
||||||
|
) {
|
||||||
|
Row(modifier = modifier.padding(4.dp)) {
|
||||||
|
SecondaryActionButton(
|
||||||
|
icon = Icons.AutoMirrored.Default.RotateLeft,
|
||||||
|
contentDescription = "Rotate left",
|
||||||
|
onClick = { onRotateImage(imageId, false) }
|
||||||
|
)
|
||||||
|
Spacer(Modifier.width(8.dp))
|
||||||
|
SecondaryActionButton(
|
||||||
|
icon = Icons.AutoMirrored.Default.RotateRight,
|
||||||
|
contentDescription = "Rotate right",
|
||||||
|
onClick = { onRotateImage(imageId, true) }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun BottomBar(
|
private fun BottomBar(
|
||||||
showPdfDialog: MutableState<Boolean>,
|
showPdfDialog: MutableState<Boolean>,
|
||||||
@@ -265,7 +296,8 @@ fun DocumentScreenPreview() {
|
|||||||
MutableStateFlow(PdfGenerationUiState()),
|
MutableStateFlow(PdfGenerationUiState()),
|
||||||
{}, {}, {}),
|
{}, {}, {}),
|
||||||
onStartNew = {},
|
onStartNew = {},
|
||||||
onDeleteImage = { _ -> {} }
|
onDeleteImage = { _ -> },
|
||||||
|
onRotateImage = { _,_ -> },
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -35,7 +35,7 @@ class ImageRepositoryTest {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fun repo(): ImageRepository {
|
fun repo(): ImageRepository {
|
||||||
return ImageRepository(getFilesDir())
|
return ImageRepository(getFilesDir(), {f1,f2,_->f1.copyTo(f2)})
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@@ -89,4 +89,32 @@ class ImageRepositoryTest {
|
|||||||
val repo2 = repo()
|
val repo2 = repo()
|
||||||
assertThat(repo2.imageIds()).isEmpty()
|
assertThat(repo2.imageIds()).isEmpty()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun rotate() {
|
||||||
|
val repo = repo()
|
||||||
|
repo.add(byteArrayOf(101, 102, 103))
|
||||||
|
val id0 = repo.imageIds().last()
|
||||||
|
val baseId = id0.substring(0, id0.length - 4)
|
||||||
|
|
||||||
|
repo.rotate(id0, true)
|
||||||
|
val id1 = repo.imageIds().last()
|
||||||
|
assertThat(id1).isEqualTo("$baseId-90.jpg")
|
||||||
|
|
||||||
|
repo.rotate(id1, true)
|
||||||
|
val id2 = repo.imageIds().last()
|
||||||
|
assertThat(id2).isEqualTo("$baseId-180.jpg")
|
||||||
|
|
||||||
|
repo.rotate(id2, true)
|
||||||
|
val id3 = repo.imageIds().last()
|
||||||
|
assertThat(id3).isEqualTo("$baseId-270.jpg")
|
||||||
|
|
||||||
|
repo.rotate(id3, true)
|
||||||
|
val id4 = repo.imageIds().last()
|
||||||
|
assertThat(id4).isEqualTo("$baseId.jpg")
|
||||||
|
|
||||||
|
repo.rotate(id4, false)
|
||||||
|
val id5 = repo.imageIds().last()
|
||||||
|
assertThat(id5).isEqualTo("$baseId-270.jpg")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user