New feature: ability to force color mode for a page

This commit is contained in:
Pierre-Yves Nicolas
2026-03-30 20:48:58 +02:00
parent 7d01493477
commit b258082ce1
26 changed files with 247 additions and 53 deletions

View File

@@ -124,8 +124,7 @@ class MainActivity : ComponentActivity() {
val currentScreen by viewModel.currentScreen.collectAsStateWithLifecycle() val currentScreen by viewModel.currentScreen.collectAsStateWithLifecycle()
val liveAnalysisState by cameraViewModel.liveAnalysisState.collectAsStateWithLifecycle() val liveAnalysisState by cameraViewModel.liveAnalysisState.collectAsStateWithLifecycle()
val document by viewModel.documentUiModel.collectAsStateWithLifecycle() val document by viewModel.documentUiModel.collectAsStateWithLifecycle()
val currentPageIndex by viewModel.currentPageIndex.collectAsStateWithLifecycle() val documentUiState by viewModel.documentUiState.collectAsStateWithLifecycle()
val currentPageBitmap by viewModel.currentPageBitmap.collectAsStateWithLifecycle()
val exportUiState by exportViewModel.uiState.collectAsStateWithLifecycle() val exportUiState by exportViewModel.uiState.collectAsStateWithLifecycle()
val cameraPermission = rememberCameraPermissionState() val cameraPermission = rememberCameraPermissionState()
CollectCameraEvents(cameraViewModel, viewModel) CollectCameraEvents(cameraViewModel, viewModel)
@@ -180,11 +179,12 @@ class MainActivity : ComponentActivity() {
} }
is Screen.Main.Document -> { is Screen.Main.Document -> {
DocumentScreen ( DocumentScreen (
uiState = DocumentUiState(currentPageIndex, currentPageBitmap, document), uiState = documentUiState,
navigation = navigation, navigation = navigation,
onExportClick = onExportClick, onExportClick = onExportClick,
onDeleteImage = { id -> viewModel.deletePage(id) }, onDeleteImage = { id -> viewModel.deletePage(id) },
onRotateImage = { id, clockwise -> viewModel.rotateImage(id, clockwise) }, onRotateImage = { id, clockwise -> viewModel.rotateImage(id, clockwise) },
onToggleColorMode = { id -> viewModel.togglePageColorMode(id) },
onPageReorder = { id, newIndex -> viewModel.movePage(id, newIndex) }, onPageReorder = { id, newIndex -> viewModel.movePage(id, newIndex) },
onPageSelected = viewModel::onPageSelected onPageSelected = viewModel::onPageSelected
) )

View File

@@ -15,13 +15,13 @@
package org.fairscan.app package org.fairscan.app
import android.graphics.Bitmap import android.graphics.Bitmap
import android.graphics.BitmapFactory
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import kotlinx.collections.immutable.persistentListOf import kotlinx.collections.immutable.persistentListOf
import kotlinx.collections.immutable.toImmutableList import kotlinx.collections.immutable.toImmutableList
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
@@ -38,10 +38,14 @@ import org.fairscan.app.domain.CapturedPage
import org.fairscan.app.domain.ScanPage import org.fairscan.app.domain.ScanPage
import org.fairscan.app.ui.NavigationState import org.fairscan.app.ui.NavigationState
import org.fairscan.app.ui.Screen import org.fairscan.app.ui.Screen
import org.fairscan.app.ui.screens.document.CurrentPageUiState
import org.fairscan.app.ui.screens.document.DocumentUiState
import org.fairscan.app.ui.state.DocumentUiModel import org.fairscan.app.ui.state.DocumentUiModel
import org.fairscan.app.ui.state.PageThumbnail import org.fairscan.app.ui.state.PageThumbnail
import org.fairscan.imageprocessing.ColorMode
import kotlin.math.min import kotlin.math.min
@OptIn(ExperimentalCoroutinesApi::class)
class MainViewModel(val imageRepository: ImageRepository, launchMode: LaunchMode): ViewModel() { class MainViewModel(val imageRepository: ImageRepository, launchMode: LaunchMode): ViewModel() {
private val _navigationState = MutableStateFlow(NavigationState.initial(launchMode)) private val _navigationState = MutableStateFlow(NavigationState.initial(launchMode))
@@ -68,21 +72,31 @@ class MainViewModel(val imageRepository: ImageRepository, launchMode: LaunchMode
.stateIn( .stateIn(
scope = viewModelScope, scope = viewModelScope,
started = SharingStarted.Eagerly, started = SharingStarted.Eagerly,
initialValue = DocumentUiModel(persistentListOf()) initialValue = DocumentUiModel()
) )
private val _currentPageIndex = MutableStateFlow(0) private val _currentPageIndex = MutableStateFlow(0)
val currentPageIndex: StateFlow<Int> =
_currentPageIndex.stateIn(viewModelScope, SharingStarted.Eagerly, 0) val currentPageUiState: Flow<CurrentPageUiState> =
@OptIn(ExperimentalCoroutinesApi::class) combine(_currentPageIndex, _pages) { index, pages -> pages.getOrNull(index) }
val currentPageBitmap: StateFlow<Bitmap?> =
_currentPageIndex
.combine(_pages) { index, pages -> pages.getOrNull(index) }
.mapLatest { page -> .mapLatest { page ->
page?.let { imageRepository.jpegBytes(it.key())?.toBitmap() } page?.let {
CurrentPageUiState(
imageRepository.jpegBytes(it.key())?.toBitmap(),
page.colorMode
)
} ?: CurrentPageUiState()
} }
.flowOn(Dispatchers.IO) .flowOn(Dispatchers.IO)
.stateIn(viewModelScope, SharingStarted.Eagerly, null)
val documentUiState: StateFlow<DocumentUiState> =
combine(_currentPageIndex, currentPageUiState, documentUiModel) { index, page, document ->
DocumentUiState(index, page, document)
}
.stateIn(
viewModelScope, SharingStarted.Eagerly,
DocumentUiState(0, CurrentPageUiState(), DocumentUiModel())
)
fun onPageSelected(index: Int) { fun onPageSelected(index: Int) {
_currentPageIndex.value = index _currentPageIndex.value = index
@@ -90,6 +104,9 @@ class MainViewModel(val imageRepository: ImageRepository, launchMode: LaunchMode
fun navigateTo(destination: Screen) { fun navigateTo(destination: Screen) {
if (destination is Screen.Main.Document) { if (destination is Screen.Main.Document) {
require(_pages.value.isNotEmpty()) {
"Cannot navigate to DocumentScreen with zero pages"
}
_currentPageIndex.value = min(_pages.value.size - 1, destination.initialPage) _currentPageIndex.value = min(_pages.value.size - 1, destination.initialPage)
} }
_navigationState.update { it.navigateTo(destination) } _navigationState.update { it.navigateTo(destination) }
@@ -125,7 +142,6 @@ class MainViewModel(val imageRepository: ImageRepository, launchMode: LaunchMode
imageRepository.delete(id) imageRepository.delete(id)
imageRepository.pages() imageRepository.pages()
} }
_pages.value = pages
if (pages.isEmpty()) { if (pages.isEmpty()) {
navigateTo(Screen.Main.Camera) navigateTo(Screen.Main.Camera)
@@ -133,6 +149,22 @@ class MainViewModel(val imageRepository: ImageRepository, launchMode: LaunchMode
} else if (_currentPageIndex.value >= pages.size) { } else if (_currentPageIndex.value >= pages.size) {
_currentPageIndex.value = pages.size - 1 _currentPageIndex.value = pages.size - 1
} }
_pages.value = pages
}
}
fun togglePageColorMode(id: String) {
viewModelScope.launch {
val currentColorMode = _pages.value.find { p -> p.id == id }?.colorMode
currentColorMode?.let {
val newColorMode =
if (it == ColorMode.COLOR) ColorMode.GRAYSCALE else ColorMode.COLOR
val pages = withContext(Dispatchers.IO) {
imageRepository.setColorMode(id, newColorMode)
imageRepository.pages()
}
_pages.value = pages
}
} }
} }

View File

@@ -15,6 +15,7 @@
package org.fairscan.app.data package org.fairscan.app.data
import kotlinx.serialization.Serializable import kotlinx.serialization.Serializable
import org.fairscan.imageprocessing.ColorMode
@Serializable @Serializable
data class DocumentMetadataV1( data class DocumentMetadataV1(
@@ -39,7 +40,8 @@ data class PageV2(
val baseRotationDegrees: Int = 0, val baseRotationDegrees: Int = 0,
val manualRotationDegrees: Int = 0, val manualRotationDegrees: Int = 0,
val quad: NormalizedQuad? = null, val quad: NormalizedQuad? = null,
val isColored: Boolean? = null val isColored: Boolean? = null,
val colorMode: ColorMode? = null,
) )
@Serializable @Serializable

View File

@@ -37,7 +37,7 @@ import org.fairscan.imageprocessing.ColorMode
import org.fairscan.imageprocessing.Point import org.fairscan.imageprocessing.Point
import org.fairscan.imageprocessing.Quad import org.fairscan.imageprocessing.Quad
import java.io.File import java.io.File
import java.util.Collections import java.util.Collections.synchronizedMap
const val SOURCE_DIR_NAME = "sources" const val SOURCE_DIR_NAME = "sources"
const val PROCESSED_DIR_NAME = "scanned_pages" const val PROCESSED_DIR_NAME = "scanned_pages"
@@ -65,11 +65,12 @@ class ImageRepository(
private val json = Json { prettyPrint = false; encodeDefaults = true } private val json = Json { prettyPrint = false; encodeDefaults = true }
private var pages: PageStore = PageStore(loadPages()) private var pages: PageStore = PageStore(loadPages())
private val processingJobs = synchronizedMap(mutableMapOf<PageViewKey, Deferred<Unit>>())
private val imageCache = createLruCache<PageViewKey, Deferred<Jpeg?>>(maxEntries = 50) private val imageCache = createLruCache<PageViewKey, Deferred<Jpeg?>>(maxEntries = 50)
private val thumbnailCache = createLruCache<PageViewKey, Deferred<Jpeg?>>(maxEntries = 200) private val thumbnailCache = createLruCache<PageViewKey, Deferred<Jpeg?>>(maxEntries = 200)
private fun <K, V> createLruCache(maxEntries: Int): MutableMap<K, V> = private fun <K, V> createLruCache(maxEntries: Int): MutableMap<K, V> =
Collections.synchronizedMap(object : LinkedHashMap<K, V>(16, 0.75f, true) { synchronizedMap(object : LinkedHashMap<K, V>(16, 0.75f, true) {
override fun removeEldestEntry(eldest: Map.Entry<K, V>) = size > maxEntries override fun removeEldestEntry(eldest: Map.Entry<K, V>) = size > maxEntries
}) })
@@ -91,7 +92,7 @@ class ImageRepository(
return when { return when {
metadataPages != null -> metadataPages != null ->
metadataPages metadataPages
.filter { "${it.id}.jpg" in filesOnDisk } .filter { processedImageFileName(it.id, it.colorMode) in filesOnDisk }
.toMutableList() .toMutableList()
else -> else ->
filesOnDisk filesOnDisk
@@ -135,7 +136,7 @@ class ImageRepository(
pages.pages().mapNotNull { pages.pages().mapNotNull {
runCatching { runCatching {
val manualRotation = Rotation.fromDegrees(it.manualRotationDegrees) val manualRotation = Rotation.fromDegrees(it.manualRotationDegrees)
ScanPage(it.id, manualRotation, it.toMetadata()) ScanPage(it.id, manualRotation, it.colorMode, it.toMetadata())
}.getOrNull() }.getOrNull()
} }
} }
@@ -143,24 +144,53 @@ class ImageRepository(
suspend fun add(processed: Jpeg, source: Jpeg, metadata: PageMetadata) = suspend fun add(processed: Jpeg, source: Jpeg, metadata: PageMetadata) =
mutex.withLock { mutex.withLock {
val id = "${System.currentTimeMillis()}" val id = "${System.currentTimeMillis()}"
val fileName = "$id.jpg" val key = PageViewKey(id, Rotation.R0, metadata.autoColorMode)
File(processedDir, fileName).writeBytes(processed.bytes) processedImageFile(key).writeBytes(processed.bytes)
File(sourceDir, fileName).writeBytes(source.bytes) sourceFile(id).writeBytes(source.bytes)
pages.addOrReplace( pages.addOrReplace(
PageV2( PageV2(
id = id, id = id,
quad = metadata.normalizedQuad.toSerializable(), quad = metadata.normalizedQuad.toSerializable(),
baseRotationDegrees = metadata.baseRotation.degrees, baseRotationDegrees = metadata.baseRotation.degrees,
manualRotationDegrees = Rotation.R0.degrees, manualRotationDegrees = Rotation.R0.degrees,
isColored = metadata.autoColorMode == ColorMode.COLOR isColored = metadata.autoColorMode == ColorMode.COLOR,
colorMode = metadata.autoColorMode,
) )
) )
saveMetadata() saveMetadata()
// Pre-populate cache for R0 // Pre-populate cache for R0
val key = PageViewKey(id, Rotation.R0)
imageCache.put(key, CompletableDeferred(processed)) imageCache.put(key, CompletableDeferred(processed))
} }
suspend fun setColorMode(id: String, colorMode: ColorMode) {
val key = PageViewKey(id, Rotation.R0, colorMode)
val processedFile = processedImageFile(key)
val metadata = mutex.withLock { pages.get(id)?.toMetadata() }
val sourceFile = sourceFile(id)
if (metadata == null || !sourceFile.exists())
return
val job = processingJobs.computeIfAbsent(key) {
scope.async(Dispatchers.IO) {
if (!processedFile.exists()) {
val sourceJpeg = Jpeg(sourceFile.readBytes())
val processedJpeg = transformations.process(sourceJpeg, metadata, colorMode)
processedFile.writeBytes(processedJpeg.bytes)
}
}
}
try {
job.await()
} finally {
processingJobs.remove(key, job)
}
mutex.withLock {
pages.update(id) { it.copy(colorMode = colorMode) }
saveMetadata()
}
}
suspend fun rotate(id: String, clockwise: Boolean) = mutex.withLock { suspend fun rotate(id: String, clockwise: Boolean) = mutex.withLock {
val page = pages.get(id) ?: return@withLock val page = pages.get(id) ?: return@withLock
val delta = if (clockwise) Rotation.R90 else Rotation.R270 val delta = if (clockwise) Rotation.R90 else Rotation.R270
@@ -198,7 +228,7 @@ class ImageRepository(
private suspend fun computeProcessedImage(key: PageViewKey): Jpeg? = private suspend fun computeProcessedImage(key: PageViewKey): Jpeg? =
withContext(Dispatchers.IO) { withContext(Dispatchers.IO) {
val baseFile = File(processedDir, "${key.pageId}.jpg") val baseFile = processedImageFile(key)
if (!baseFile.exists()) return@withContext null if (!baseFile.exists()) return@withContext null
val baseJpeg = Jpeg(baseFile.readBytes()) val baseJpeg = Jpeg(baseFile.readBytes())
if (key.rotation == Rotation.R0) { if (key.rotation == Rotation.R0) {
@@ -220,8 +250,20 @@ class ImageRepository(
// --- Other operations --- // --- Other operations ---
private fun processedImageFileName(id: String, colorMode: ColorMode?) : String =
if (colorMode == null)
"${id}.jpg"
else
"${id}.${colorMode.name.lowercase()}.jpg"
private fun processedImageFile(key: PageViewKey) : File =
File(processedDir, processedImageFileName(key.pageId, key.colorMode))
private fun sourceFile(id: String): File =
File(sourceDir, "$id.jpg")
fun source(id: String): Jpeg? { fun source(id: String): Jpeg? {
val file = File(sourceDir, "$id.jpg") val file = sourceFile(id)
return if (file.exists()) Jpeg(file.readBytes()) else null return if (file.exists()) Jpeg(file.readBytes()) else null
} }
@@ -233,7 +275,7 @@ class ImageRepository(
suspend fun delete(id: String) = mutex.withLock { suspend fun delete(id: String) = mutex.withLock {
pages.delete(id) pages.delete(id)
saveMetadata() saveMetadata()
File(sourceDir, "$id.jpg").delete() sourceFile(id).delete()
processedDir.listFiles() processedDir.listFiles()
?.filter { it.name.startsWith("$id.") || it.name.startsWith("$id-") } ?.filter { it.name.startsWith("$id.") || it.name.startsWith("$id-") }
?.forEach { it.delete() } ?.forEach { it.delete() }

View File

@@ -15,6 +15,8 @@
package org.fairscan.app.data package org.fairscan.app.data
import org.fairscan.app.domain.Jpeg import org.fairscan.app.domain.Jpeg
import org.fairscan.app.domain.PageMetadata
import org.fairscan.imageprocessing.ColorMode
interface ImageTransformations { interface ImageTransformations {
@@ -22,4 +24,6 @@ interface ImageTransformations {
fun resize(input: Jpeg, maxSize: Int): Jpeg fun resize(input: Jpeg, maxSize: Int): Jpeg
fun process(source: Jpeg, metadata: PageMetadata, colorMode: ColorMode): Jpeg
} }

View File

@@ -15,6 +15,7 @@
package org.fairscan.app.domain package org.fairscan.app.domain
import org.fairscan.app.data.ImageRepository import org.fairscan.app.data.ImageRepository
import org.fairscan.imageprocessing.ColorMode
import org.fairscan.imageprocessing.extractDocument import org.fairscan.imageprocessing.extractDocument
import org.fairscan.imageprocessing.resizeForMaxPixels import org.fairscan.imageprocessing.resizeForMaxPixels
import org.fairscan.imageprocessing.scaledTo import org.fairscan.imageprocessing.scaledTo
@@ -48,10 +49,11 @@ suspend fun jpegsForExport(
ExportQuality.HIGH -> pages.map { page -> ExportQuality.HIGH -> pages.map { page ->
JpegProvider { JpegProvider {
val source = imageRepository.source(page.id) val source = imageRepository.source(page.id)
val pageMetadata = page.metadata val metadata = page.metadata
val manualRotation = page.manualRotation val manualRotation = page.manualRotation
if (source != null && pageMetadata != null) val colorMode = page.colorMode
prepareJpegForHigh(source, pageMetadata, manualRotation, exportQuality) if (source != null && metadata != null && colorMode != null)
prepareJpegForHigh(source, metadata, manualRotation, colorMode, exportQuality)
else else
jpeg(page, imageRepository) jpeg(page, imageRepository)
} }
@@ -86,6 +88,7 @@ private fun prepareJpegForHigh(
source: Jpeg, source: Jpeg,
pageMetadata: PageMetadata, pageMetadata: PageMetadata,
manualRotation: Rotation, manualRotation: Rotation,
colorMode: ColorMode,
exportQuality: ExportQuality, exportQuality: ExportQuality,
): Jpeg { ): Jpeg {
@@ -94,13 +97,8 @@ private fun prepareJpegForHigh(
try { try {
decoded = source.toMat() decoded = source.toMat()
val quad = pageMetadata.normalizedQuad.scaledTo(1, 1, decoded.width(), decoded.height()) val quad = pageMetadata.normalizedQuad.scaledTo(1, 1, decoded.width(), decoded.height())
page = extractDocument( val rotationDegrees = pageMetadata.baseRotation.add(manualRotation).degrees
decoded, page = extractDocument(decoded, quad, rotationDegrees, colorMode, exportQuality.maxPixels)
quad,
pageMetadata.baseRotation.add(manualRotation).degrees,
pageMetadata.autoColorMode,
exportQuality.maxPixels
)
return Jpeg.fromMat(page, exportQuality.jpegQuality) return Jpeg.fromMat(page, exportQuality.jpegQuality)
} finally { } finally {
decoded?.release() decoded?.release()

View File

@@ -26,16 +26,18 @@ data class PageMetadata(
data class ScanPage( data class ScanPage(
val id: String, val id: String,
val manualRotation: Rotation, val manualRotation: Rotation,
val colorMode: ColorMode?,
val metadata: PageMetadata?, val metadata: PageMetadata?,
) { ) {
fun key(): PageViewKey = PageViewKey(id, manualRotation) fun key(): PageViewKey = PageViewKey(id, manualRotation, colorMode)
} }
data class PageViewKey( data class PageViewKey(
val pageId: String, val pageId: String,
val rotation: Rotation, val rotation: Rotation,
val colorMode: ColorMode?,
) { ) {
val saveKey: String get() = "$pageId-${rotation.degrees}" val saveKey: String get() = "$pageId-${rotation.degrees}-$colorMode"
} }
enum class Rotation(val degrees: Int) { enum class Rotation(val degrees: Int) {

View File

@@ -15,7 +15,12 @@
package org.fairscan.app.platform package org.fairscan.app.platform
import org.fairscan.app.data.ImageTransformations import org.fairscan.app.data.ImageTransformations
import org.fairscan.app.domain.ExportQuality
import org.fairscan.app.domain.Jpeg import org.fairscan.app.domain.Jpeg
import org.fairscan.app.domain.PageMetadata
import org.fairscan.imageprocessing.ColorMode
import org.fairscan.imageprocessing.extractDocument
import org.fairscan.imageprocessing.scaledTo
import org.opencv.core.Mat import org.opencv.core.Mat
import org.opencv.core.Size import org.opencv.core.Size
import org.opencv.imgproc.Imgproc import org.opencv.imgproc.Imgproc
@@ -59,4 +64,30 @@ class OpenCvTransformations : ImageTransformations {
output?.release() output?.release()
} }
} }
override fun process(source: Jpeg, metadata: PageMetadata, colorMode: ColorMode): Jpeg {
val exportQuality = ExportQuality.BALANCED
var sourceMat: Mat? = null
var page: Mat? = null
try {
sourceMat = source.toMat()
val quad = metadata.normalizedQuad.scaledTo(
1,
1,
sourceMat.width(),
sourceMat.height()
)
page = extractDocument(
sourceMat,
quad,
metadata.baseRotation.degrees,
colorMode,
exportQuality.maxPixels
)
return Jpeg.fromMat(page, exportQuality.jpegQuality)
} finally {
sourceMat?.release()
page?.release()
}
}
} }

View File

@@ -24,6 +24,7 @@ import org.fairscan.app.domain.PageViewKey
import org.fairscan.app.domain.Rotation import org.fairscan.app.domain.Rotation
import org.fairscan.app.ui.state.DocumentUiModel import org.fairscan.app.ui.state.DocumentUiModel
import org.fairscan.app.ui.state.PageThumbnail import org.fairscan.app.ui.state.PageThumbnail
import org.fairscan.imageprocessing.ColorMode
fun dummyNavigation(): Navigation { fun dummyNavigation(): Navigation {
return Navigation({}, {}, {}, {}, {}, {}, {}, {}) return Navigation({}, {}, {}, {}, {}, {}, {}, {})
@@ -35,7 +36,7 @@ fun fakeDocument(): DocumentUiModel {
fun fakeDocument(pageIds: ImmutableList<String>, context: Context): DocumentUiModel { fun fakeDocument(pageIds: ImmutableList<String>, context: Context): DocumentUiModel {
val pageKeys = pageIds.map { val pageKeys = pageIds.map {
PageThumbnail(PageViewKey(it, Rotation.R0), fakeImage(it, context)) PageThumbnail(PageViewKey(it, Rotation.R0, ColorMode.COLOR), fakeImage(it, context))
}.toImmutableList() }.toImmutableList()
return DocumentUiModel(pageKeys) return DocumentUiModel(pageKeys)
} }

View File

@@ -31,7 +31,9 @@ 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.filled.Contrast
import androidx.compose.material.icons.filled.Done import androidx.compose.material.icons.filled.Done
import androidx.compose.material.icons.filled.Palette
import androidx.compose.material.icons.filled.RotateLeft import androidx.compose.material.icons.filled.RotateLeft
import androidx.compose.material.icons.filled.RotateRight import androidx.compose.material.icons.filled.RotateRight
import androidx.compose.material.icons.outlined.Add import androidx.compose.material.icons.outlined.Add
@@ -74,6 +76,9 @@ import org.fairscan.app.ui.dummyNavigation
import org.fairscan.app.ui.fakeDocument import org.fairscan.app.ui.fakeDocument
import org.fairscan.app.ui.fakeImage import org.fairscan.app.ui.fakeImage
import org.fairscan.app.ui.theme.FairScanTheme import org.fairscan.app.ui.theme.FairScanTheme
import org.fairscan.imageprocessing.ColorMode
import org.fairscan.imageprocessing.ColorMode.COLOR
import org.fairscan.imageprocessing.ColorMode.GRAYSCALE
@OptIn(ExperimentalMaterial3Api::class) @OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
@@ -83,6 +88,7 @@ fun DocumentScreen(
onExportClick: () -> Unit, onExportClick: () -> Unit,
onDeleteImage: (String) -> Unit, onDeleteImage: (String) -> Unit,
onRotateImage: (String, Boolean) -> Unit, onRotateImage: (String, Boolean) -> Unit,
onToggleColorMode: (String) -> Unit,
onPageReorder: (String, Int) -> Unit, onPageReorder: (String, Int) -> Unit,
onPageSelected: (Int) -> Unit, onPageSelected: (Int) -> Unit,
) { ) {
@@ -116,6 +122,7 @@ fun DocumentScreen(
uiState, uiState,
{ showDeletePageDialog.value = true }, { showDeletePageDialog.value = true },
onRotateImage, onRotateImage,
onToggleColorMode,
modifier modifier
) )
if (showDeletePageDialog.value) { if (showDeletePageDialog.value) {
@@ -133,6 +140,7 @@ private fun DocumentPreview(
uiState: DocumentUiState, uiState: DocumentUiState,
onDeleteImage: (String) -> Unit, onDeleteImage: (String) -> Unit,
onRotateImage: (String, Boolean) -> Unit, onRotateImage: (String, Boolean) -> Unit,
onToggleColorMode: (String) -> Unit,
modifier: Modifier, modifier: Modifier,
) { ) {
val currentPageIndex = uiState.currentPageIndex val currentPageIndex = uiState.currentPageIndex
@@ -145,7 +153,7 @@ private fun DocumentPreview(
Box ( Box (
modifier = Modifier.fillMaxSize() modifier = Modifier.fillMaxSize()
) { ) {
val bitmap = uiState.currentPageBitmap val bitmap = uiState.currentPage.bitmap
if (bitmap != null) { if (bitmap != null) {
val imageBitmap = bitmap.asImageBitmap() val imageBitmap = bitmap.asImageBitmap()
val zoomState = remember(imageId) { val zoomState = remember(imageId) {
@@ -167,6 +175,15 @@ private fun DocumentPreview(
) )
} }
} }
uiState.currentPage.colorMode?.let {
ColorModeButton(
currentColorMode = it,
onToggle = { onToggleColorMode(imageId) },
modifier = Modifier
.align(Alignment.BottomStart)
.padding(8.dp)
)
}
RotationButtons(imageId, onRotateImage, Modifier.align(Alignment.BottomCenter)) RotationButtons(imageId, onRotateImage, Modifier.align(Alignment.BottomCenter))
SecondaryActionButton( SecondaryActionButton(
Icons.Outlined.Delete, Icons.Outlined.Delete,
@@ -179,10 +196,10 @@ private fun DocumentPreview(
Text("${currentPageIndex + 1} / ${document.pageCount()}", Text("${currentPageIndex + 1} / ${document.pageCount()}",
color = MaterialTheme.colorScheme.inverseOnSurface, color = MaterialTheme.colorScheme.inverseOnSurface,
modifier = Modifier modifier = Modifier
.align(Alignment.BottomStart) .align(Alignment.TopCenter)
.padding(all = 16.dp) .padding(all = 16.dp)
.background( .background(
color = MaterialTheme.colorScheme.inverseSurface.copy(alpha = 0.5f), color = MaterialTheme.colorScheme.inverseSurface.copy(alpha = 0.7f),
shape = RoundedCornerShape(4.dp) shape = RoundedCornerShape(4.dp)
) )
.padding(horizontal = 8.dp, vertical = 2.dp) .padding(horizontal = 8.dp, vertical = 2.dp)
@@ -199,7 +216,7 @@ fun RotationButtons(
) { ) {
// RotateLeft on the left, RotateRight on the right: for both LTR and RTL languages // RotateLeft on the left, RotateRight on the right: for both LTR and RTL languages
CompositionLocalProvider(LocalLayoutDirection provides LayoutDirection.Ltr) { CompositionLocalProvider(LocalLayoutDirection provides LayoutDirection.Ltr) {
Row(modifier = modifier.padding(4.dp)) { Row(modifier = modifier.padding(8.dp)) {
// Using AutoMirrored icons would lead to an opposite rotation in RTL languages // Using AutoMirrored icons would lead to an opposite rotation in RTL languages
@Suppress("DEPRECATION") @Suppress("DEPRECATION")
SecondaryActionButton( SecondaryActionButton(
@@ -218,6 +235,32 @@ fun RotationButtons(
} }
} }
@Composable
fun ColorModeButton(
currentColorMode: ColorMode,
onToggle: () -> Unit,
modifier: Modifier = Modifier
) {
val icon = when (currentColorMode) {
COLOR -> Icons.Default.Palette
GRAYSCALE -> Icons.Default.Contrast
}
val label = when (currentColorMode) {
COLOR -> stringResource(R.string.color_mode_color)
GRAYSCALE -> stringResource(R.string.color_mode_grayscale)
}
Column(
horizontalAlignment = Alignment.CenterHorizontally,
modifier = modifier
) {
SecondaryActionButton(
icon = icon,
contentDescription = label,
onClick = onToggle,
)
}
}
@Composable @Composable
private fun BottomBar( private fun BottomBar(
onExportClick: () -> Unit, onExportClick: () -> Unit,
@@ -264,11 +307,12 @@ fun DocumentScreenPreview() {
LocalContext.current LocalContext.current
) )
DocumentScreen( DocumentScreen(
uiState = DocumentUiState(1, image, document), uiState = DocumentUiState(1, CurrentPageUiState(image, COLOR), document),
navigation = dummyNavigation(), navigation = dummyNavigation(),
onExportClick = {}, onExportClick = {},
onDeleteImage = { _ -> }, onDeleteImage = { _ -> },
onRotateImage = { _,_ -> }, onRotateImage = { _,_ -> },
onToggleColorMode = { _ -> },
onPageReorder = { _,_ -> }, onPageReorder = { _,_ -> },
onPageSelected = { _ -> }, onPageSelected = { _ -> },
) )

View File

@@ -16,9 +16,15 @@ package org.fairscan.app.ui.screens.document
import android.graphics.Bitmap import android.graphics.Bitmap
import org.fairscan.app.ui.state.DocumentUiModel import org.fairscan.app.ui.state.DocumentUiModel
import org.fairscan.imageprocessing.ColorMode
data class DocumentUiState( data class DocumentUiState(
val currentPageIndex: Int, val currentPageIndex: Int,
val currentPageBitmap: Bitmap?, val currentPage: CurrentPageUiState,
val document: DocumentUiModel, val document: DocumentUiModel,
) )
data class CurrentPageUiState(
val bitmap: Bitmap? = null,
val colorMode: ColorMode? = null,
)

View File

@@ -16,10 +16,11 @@ package org.fairscan.app.ui.state
import android.graphics.Bitmap import android.graphics.Bitmap
import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.persistentListOf
import org.fairscan.app.domain.PageViewKey import org.fairscan.app.domain.PageViewKey
data class DocumentUiModel( data class DocumentUiModel(
val pages: ImmutableList<PageThumbnail>, val pages: ImmutableList<PageThumbnail> = persistentListOf(),
) { ) {
fun pageCount(): Int { fun pageCount(): Int {
return pages.size return pages.size

View File

@@ -8,6 +8,8 @@
<string name="cancel">ألغِ</string> <string name="cancel">ألغِ</string>
<string name="change_directory">غيّر المجلد</string> <string name="change_directory">غيّر المجلد</string>
<string name="clear_text">امحُ النص</string> <string name="clear_text">امحُ النص</string>
<string name="color_mode_color">ألوان</string>
<string name="color_mode_grayscale">تدرج الرمادي</string>
<string name="contact">تواصل</string> <string name="contact">تواصل</string>
<string name="copied_logs">نُسخت السجلات إلى الحافظة</string> <string name="copied_logs">نُسخت السجلات إلى الحافظة</string>
<string name="copy_logs">انسخ السجلات</string> <string name="copy_logs">انسخ السجلات</string>

View File

@@ -8,6 +8,8 @@
<string name="cancel">Zrušit</string> <string name="cancel">Zrušit</string>
<string name="change_directory">Změnit složku</string> <string name="change_directory">Změnit složku</string>
<string name="clear_text">Smazat text</string> <string name="clear_text">Smazat text</string>
<string name="color_mode_color">Barva</string>
<string name="color_mode_grayscale">Odstíny šedi</string>
<string name="contact">Kontakt</string> <string name="contact">Kontakt</string>
<string name="copied_logs">Protokoly zkopírovány do schránky</string> <string name="copied_logs">Protokoly zkopírovány do schránky</string>
<string name="copy_logs">Kopírovat protokoly</string> <string name="copy_logs">Kopírovat protokoly</string>

View File

@@ -8,6 +8,8 @@
<string name="cancel">Abbrechen</string> <string name="cancel">Abbrechen</string>
<string name="change_directory">Ordner ändern</string> <string name="change_directory">Ordner ändern</string>
<string name="clear_text">Text löschen</string> <string name="clear_text">Text löschen</string>
<string name="color_mode_color">Farbe</string>
<string name="color_mode_grayscale">Graustufen</string>
<string name="contact">Kontakt</string> <string name="contact">Kontakt</string>
<string name="copied_logs">Logs in die Zwischenablage kopiert</string> <string name="copied_logs">Logs in die Zwischenablage kopiert</string>
<string name="copy_logs">Logs kopieren</string> <string name="copy_logs">Logs kopieren</string>

View File

@@ -8,6 +8,8 @@
<string name="cancel">Cancelar</string> <string name="cancel">Cancelar</string>
<string name="change_directory">Cambiar carpeta</string> <string name="change_directory">Cambiar carpeta</string>
<string name="clear_text">Borrar texto</string> <string name="clear_text">Borrar texto</string>
<string name="color_mode_color">Color</string>
<string name="color_mode_grayscale">Escala de grises</string>
<string name="contact">Contacto</string> <string name="contact">Contacto</string>
<string name="copied_logs">Registros copiados al portapapeles</string> <string name="copied_logs">Registros copiados al portapapeles</string>
<string name="copy_logs">Copiar registros</string> <string name="copy_logs">Copiar registros</string>

View File

@@ -8,6 +8,8 @@
<string name="cancel">Annuler</string> <string name="cancel">Annuler</string>
<string name="change_directory">Changer de dossier</string> <string name="change_directory">Changer de dossier</string>
<string name="clear_text">Effacer le text</string> <string name="clear_text">Effacer le text</string>
<string name="color_mode_color">Couleur</string>
<string name="color_mode_grayscale">Niveaux de gris</string>
<string name="contact">Contact</string> <string name="contact">Contact</string>
<string name="copied_logs">Logs copiés dans le presse-papiers</string> <string name="copied_logs">Logs copiés dans le presse-papiers</string>
<string name="copy_logs">Copier les logs</string> <string name="copy_logs">Copier les logs</string>

View File

@@ -8,6 +8,8 @@
<string name="cancel">Cancelar</string> <string name="cancel">Cancelar</string>
<string name="change_directory">Trocar cartafol</string> <string name="change_directory">Trocar cartafol</string>
<string name="clear_text">Borrar texto</string> <string name="clear_text">Borrar texto</string>
<string name="color_mode_color">Cor</string>
<string name="color_mode_grayscale">Escala de grises</string>
<string name="contact">Contacto</string> <string name="contact">Contacto</string>
<string name="copied_logs">Rexistros copiados ao portapapeis</string> <string name="copied_logs">Rexistros copiados ao portapapeis</string>
<string name="copy_logs">Copiar rexistros</string> <string name="copy_logs">Copiar rexistros</string>

View File

@@ -8,6 +8,8 @@
<string name="cancel">Annulla</string> <string name="cancel">Annulla</string>
<string name="change_directory">Cambia cartella</string> <string name="change_directory">Cambia cartella</string>
<string name="clear_text">Svuota testo</string> <string name="clear_text">Svuota testo</string>
<string name="color_mode_color">Colore</string>
<string name="color_mode_grayscale">Scala di grigi</string>
<string name="contact">Contatti</string> <string name="contact">Contatti</string>
<string name="copied_logs">Log copiati negli appunti</string> <string name="copied_logs">Log copiati negli appunti</string>
<string name="copy_logs">Copia log</string> <string name="copy_logs">Copia log</string>

View File

@@ -8,6 +8,8 @@
<string name="cancel">Cancelar</string> <string name="cancel">Cancelar</string>
<string name="change_directory">Alterar diretório</string> <string name="change_directory">Alterar diretório</string>
<string name="clear_text">Limpar texto</string> <string name="clear_text">Limpar texto</string>
<string name="color_mode_color">Cor</string>
<string name="color_mode_grayscale">Escala de cinza</string>
<string name="contact">Contato</string> <string name="contact">Contato</string>
<string name="copied_logs">Registros copiados para a área de transferência</string> <string name="copied_logs">Registros copiados para a área de transferência</string>
<string name="copy_logs">Copiar registros</string> <string name="copy_logs">Copiar registros</string>

View File

@@ -8,6 +8,8 @@
<string name="cancel">Отмена</string> <string name="cancel">Отмена</string>
<string name="change_directory">Изменить папку</string> <string name="change_directory">Изменить папку</string>
<string name="clear_text">Стереть текст</string> <string name="clear_text">Стереть текст</string>
<string name="color_mode_color">Цвет</string>
<string name="color_mode_grayscale">Оттенки серого</string>
<string name="contact">Контакты</string> <string name="contact">Контакты</string>
<string name="copied_logs">Журналы скопированы в буфер обмена</string> <string name="copied_logs">Журналы скопированы в буфер обмена</string>
<string name="copy_logs">Копировать журналы</string> <string name="copy_logs">Копировать журналы</string>

View File

@@ -8,6 +8,8 @@
<string name="cancel">İptal</string> <string name="cancel">İptal</string>
<string name="change_directory">Dizini değiştir</string> <string name="change_directory">Dizini değiştir</string>
<string name="clear_text">Metni temizle</string> <string name="clear_text">Metni temizle</string>
<string name="color_mode_color">Renkli</string>
<string name="color_mode_grayscale">Gri tonlama</string>
<string name="contact">İletişim</string> <string name="contact">İletişim</string>
<string name="copied_logs">Günlükler panoya kopyalandı</string> <string name="copied_logs">Günlükler panoya kopyalandı</string>
<string name="copy_logs">Günlükleri kopyala</string> <string name="copy_logs">Günlükleri kopyala</string>

View File

@@ -8,6 +8,8 @@
<string name="cancel">取消</string> <string name="cancel">取消</string>
<string name="change_directory">變更目錄</string> <string name="change_directory">變更目錄</string>
<string name="clear_text">清除文字</string> <string name="clear_text">清除文字</string>
<string name="color_mode_color">彩色</string>
<string name="color_mode_grayscale">灰階</string>
<string name="contact">聯絡我們</string> <string name="contact">聯絡我們</string>
<string name="copied_logs">日誌已複製到剪貼簿</string> <string name="copied_logs">日誌已複製到剪貼簿</string>
<string name="copy_logs">複製日誌</string> <string name="copy_logs">複製日誌</string>

View File

@@ -8,6 +8,8 @@
<string name="cancel">取消</string> <string name="cancel">取消</string>
<string name="change_directory">更改目录</string> <string name="change_directory">更改目录</string>
<string name="clear_text">清除文字</string> <string name="clear_text">清除文字</string>
<string name="color_mode_color">彩色</string>
<string name="color_mode_grayscale">灰度</string>
<string name="contact">联系人</string> <string name="contact">联系人</string>
<string name="copied_logs">日志已复制到剪贴板</string> <string name="copied_logs">日志已复制到剪贴板</string>
<string name="copy_logs">复制日志</string> <string name="copy_logs">复制日志</string>

View File

@@ -9,6 +9,8 @@
<string name="cancel">Cancel</string> <string name="cancel">Cancel</string>
<string name="change_directory">Change folder</string> <string name="change_directory">Change folder</string>
<string name="clear_text">Clear text</string> <string name="clear_text">Clear text</string>
<string name="color_mode_color">Color</string>
<string name="color_mode_grayscale">Grayscale</string>
<string name="contact">Contact</string> <string name="contact">Contact</string>
<string name="copied_logs">Logs copied to clipboard</string> <string name="copied_logs">Logs copied to clipboard</string>
<string name="copy_logs">Copy logs</string> <string name="copy_logs">Copy logs</string>

View File

@@ -27,6 +27,7 @@ import org.fairscan.app.domain.Rotation.R180
import org.fairscan.app.domain.Rotation.R270 import org.fairscan.app.domain.Rotation.R270
import org.fairscan.app.domain.Rotation.R90 import org.fairscan.app.domain.Rotation.R90
import org.fairscan.imageprocessing.ColorMode import org.fairscan.imageprocessing.ColorMode
import org.fairscan.imageprocessing.ColorMode.COLOR
import org.fairscan.imageprocessing.Point import org.fairscan.imageprocessing.Point
import org.fairscan.imageprocessing.Quad import org.fairscan.imageprocessing.Quad
import org.junit.Rule import org.junit.Rule
@@ -44,7 +45,7 @@ class ImageRepositoryTest {
private val testScope = TestScope() private val testScope = TestScope()
val quad1 = Quad(Point(.01, .02), Point(.1, .03), Point(.11, .12), Point(.03, .09)) val quad1 = Quad(Point(.01, .02), Point(.1, .03), Point(.11, .12), Point(.03, .09))
val metadata1 = PageMetadata(quad1, R90, ColorMode.COLOR) val metadata1 = PageMetadata(quad1, R90, COLOR)
fun getFilesDir(): File { fun getFilesDir(): File {
if (_filesDir == null) { if (_filesDir == null) {
@@ -61,6 +62,10 @@ class ImageRepositoryTest {
override fun resize(input: Jpeg, maxSize: Int): Jpeg { override fun resize(input: Jpeg, maxSize: Int): Jpeg {
return jpeg(input.bytes[0]) return jpeg(input.bytes[0])
} }
override fun process(source: Jpeg, metadata: PageMetadata, colorMode: ColorMode): Jpeg {
TODO("Not yet implemented")
}
} }
return ImageRepository(getFilesDir(), transformations, 200, testScope) return ImageRepository(getFilesDir(), transformations, 200, testScope)
} }
@@ -73,7 +78,7 @@ class ImageRepositoryTest {
repo.add(jpeg, jpeg(51), metadata1) repo.add(jpeg, jpeg(51), metadata1)
assertThat(repo.imageIds()).hasSize(1) assertThat(repo.imageIds()).hasSize(1)
val id = repo.imageIds()[0] val id = repo.imageIds()[0]
val key = PageViewKey(id, R0) val key = PageViewKey(id, R0, COLOR)
assertThat(repo.jpegBytes(key)).isEqualTo(jpeg) assertThat(repo.jpegBytes(key)).isEqualTo(jpeg)
assertThat(repo.getThumbnail(key)?.bytes).isEqualTo(byteArrayOf(101)) assertThat(repo.getThumbnail(key)?.bytes).isEqualTo(byteArrayOf(101))
@@ -140,7 +145,7 @@ class ImageRepositoryTest {
File(processedDir(), "1-90.jpg").writeBytes(bytes) File(processedDir(), "1-90.jpg").writeBytes(bytes)
val repo = repo() val repo = repo()
assertThat(repo.imageIds()).containsExactly("1") assertThat(repo.imageIds()).containsExactly("1")
assertThat(repo.jpegBytes(PageViewKey("1", R0))?.bytes).isEqualTo(bytes) assertThat(repo.jpegBytes(PageViewKey("1", R0, null))?.bytes).isEqualTo(bytes)
} }
@Test @Test
@@ -169,14 +174,14 @@ class ImageRepositoryTest {
File(processedDir(), "1-90.jpg").writeBytes(bytes) File(processedDir(), "1-90.jpg").writeBytes(bytes)
val repo = repo() val repo = repo()
assertThat(repo.imageIds()).containsExactly("1") assertThat(repo.imageIds()).containsExactly("1")
assertThat(repo.jpegBytes(PageViewKey("1", R0))?.bytes).isEqualTo(bytes) assertThat(repo.jpegBytes(PageViewKey("1", R0, null))?.bytes).isEqualTo(bytes)
} }
@Test @Test
fun `should return null on invalid id`() = runTest { fun `should return null on invalid id`() = runTest {
val repo = repo() val repo = repo()
assertThat(repo.imageIds()).isEmpty() assertThat(repo.imageIds()).isEmpty()
assertThat(repo.jpegBytes(PageViewKey("x", R0))).isNull() assertThat(repo.jpegBytes(PageViewKey("x", R0, COLOR))).isNull()
} }
@Test @Test
@@ -255,7 +260,7 @@ class ImageRepositoryTest {
val metadata = PageV2("1", 0, 0, quad, isColored).toMetadata() val metadata = PageV2("1", 0, 0, quad, isColored).toMetadata()
assertThat(metadata).isNotNull() assertThat(metadata).isNotNull()
assertThat(metadata!!.autoColorMode).isEqualTo( assertThat(metadata!!.autoColorMode).isEqualTo(
if (isColored) ColorMode.COLOR else ColorMode.GRAYSCALE if (isColored) COLOR else ColorMode.GRAYSCALE
) )
} }
} }