diff --git a/app/src/main/java/org/fairscan/app/MainActivity.kt b/app/src/main/java/org/fairscan/app/MainActivity.kt index 430eede..59e63c0 100644 --- a/app/src/main/java/org/fairscan/app/MainActivity.kt +++ b/app/src/main/java/org/fairscan/app/MainActivity.kt @@ -124,8 +124,7 @@ class MainActivity : ComponentActivity() { val currentScreen by viewModel.currentScreen.collectAsStateWithLifecycle() val liveAnalysisState by cameraViewModel.liveAnalysisState.collectAsStateWithLifecycle() val document by viewModel.documentUiModel.collectAsStateWithLifecycle() - val currentPageIndex by viewModel.currentPageIndex.collectAsStateWithLifecycle() - val currentPageBitmap by viewModel.currentPageBitmap.collectAsStateWithLifecycle() + val documentUiState by viewModel.documentUiState.collectAsStateWithLifecycle() val exportUiState by exportViewModel.uiState.collectAsStateWithLifecycle() val cameraPermission = rememberCameraPermissionState() CollectCameraEvents(cameraViewModel, viewModel) @@ -180,11 +179,12 @@ class MainActivity : ComponentActivity() { } is Screen.Main.Document -> { DocumentScreen ( - uiState = DocumentUiState(currentPageIndex, currentPageBitmap, document), + uiState = documentUiState, navigation = navigation, onExportClick = onExportClick, onDeleteImage = { id -> viewModel.deletePage(id) }, onRotateImage = { id, clockwise -> viewModel.rotateImage(id, clockwise) }, + onToggleColorMode = { id -> viewModel.togglePageColorMode(id) }, onPageReorder = { id, newIndex -> viewModel.movePage(id, newIndex) }, onPageSelected = viewModel::onPageSelected ) diff --git a/app/src/main/java/org/fairscan/app/MainViewModel.kt b/app/src/main/java/org/fairscan/app/MainViewModel.kt index c7361da..9b683dd 100644 --- a/app/src/main/java/org/fairscan/app/MainViewModel.kt +++ b/app/src/main/java/org/fairscan/app/MainViewModel.kt @@ -15,13 +15,13 @@ package org.fairscan.app import android.graphics.Bitmap -import android.graphics.BitmapFactory import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import kotlinx.collections.immutable.persistentListOf import kotlinx.collections.immutable.toImmutableList import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharingStarted 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.ui.NavigationState 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.PageThumbnail +import org.fairscan.imageprocessing.ColorMode import kotlin.math.min +@OptIn(ExperimentalCoroutinesApi::class) class MainViewModel(val imageRepository: ImageRepository, launchMode: LaunchMode): ViewModel() { private val _navigationState = MutableStateFlow(NavigationState.initial(launchMode)) @@ -68,21 +72,31 @@ class MainViewModel(val imageRepository: ImageRepository, launchMode: LaunchMode .stateIn( scope = viewModelScope, started = SharingStarted.Eagerly, - initialValue = DocumentUiModel(persistentListOf()) + initialValue = DocumentUiModel() ) private val _currentPageIndex = MutableStateFlow(0) - val currentPageIndex: StateFlow = - _currentPageIndex.stateIn(viewModelScope, SharingStarted.Eagerly, 0) - @OptIn(ExperimentalCoroutinesApi::class) - val currentPageBitmap: StateFlow = - _currentPageIndex - .combine(_pages) { index, pages -> pages.getOrNull(index) } + + val currentPageUiState: Flow = + combine(_currentPageIndex, _pages) { index, pages -> pages.getOrNull(index) } .mapLatest { page -> - page?.let { imageRepository.jpegBytes(it.key())?.toBitmap() } + page?.let { + CurrentPageUiState( + imageRepository.jpegBytes(it.key())?.toBitmap(), + page.colorMode + ) + } ?: CurrentPageUiState() } .flowOn(Dispatchers.IO) - .stateIn(viewModelScope, SharingStarted.Eagerly, null) + + val documentUiState: StateFlow = + combine(_currentPageIndex, currentPageUiState, documentUiModel) { index, page, document -> + DocumentUiState(index, page, document) + } + .stateIn( + viewModelScope, SharingStarted.Eagerly, + DocumentUiState(0, CurrentPageUiState(), DocumentUiModel()) + ) fun onPageSelected(index: Int) { _currentPageIndex.value = index @@ -90,6 +104,9 @@ class MainViewModel(val imageRepository: ImageRepository, launchMode: LaunchMode fun navigateTo(destination: Screen) { 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) } _navigationState.update { it.navigateTo(destination) } @@ -125,7 +142,6 @@ class MainViewModel(val imageRepository: ImageRepository, launchMode: LaunchMode imageRepository.delete(id) imageRepository.pages() } - _pages.value = pages if (pages.isEmpty()) { navigateTo(Screen.Main.Camera) @@ -133,6 +149,22 @@ class MainViewModel(val imageRepository: ImageRepository, launchMode: LaunchMode } else if (_currentPageIndex.value >= pages.size) { _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 + } } } diff --git a/app/src/main/java/org/fairscan/app/data/DocumentMetadata.kt b/app/src/main/java/org/fairscan/app/data/DocumentMetadata.kt index 22792ce..89b1c5e 100644 --- a/app/src/main/java/org/fairscan/app/data/DocumentMetadata.kt +++ b/app/src/main/java/org/fairscan/app/data/DocumentMetadata.kt @@ -15,6 +15,7 @@ package org.fairscan.app.data import kotlinx.serialization.Serializable +import org.fairscan.imageprocessing.ColorMode @Serializable data class DocumentMetadataV1( @@ -39,7 +40,8 @@ data class PageV2( val baseRotationDegrees: Int = 0, val manualRotationDegrees: Int = 0, val quad: NormalizedQuad? = null, - val isColored: Boolean? = null + val isColored: Boolean? = null, + val colorMode: ColorMode? = null, ) @Serializable diff --git a/app/src/main/java/org/fairscan/app/data/ImageRepository.kt b/app/src/main/java/org/fairscan/app/data/ImageRepository.kt index 6c6a0ed..8cea2e4 100644 --- a/app/src/main/java/org/fairscan/app/data/ImageRepository.kt +++ b/app/src/main/java/org/fairscan/app/data/ImageRepository.kt @@ -37,7 +37,7 @@ import org.fairscan.imageprocessing.ColorMode import org.fairscan.imageprocessing.Point import org.fairscan.imageprocessing.Quad import java.io.File -import java.util.Collections +import java.util.Collections.synchronizedMap const val SOURCE_DIR_NAME = "sources" const val PROCESSED_DIR_NAME = "scanned_pages" @@ -65,11 +65,12 @@ class ImageRepository( private val json = Json { prettyPrint = false; encodeDefaults = true } private var pages: PageStore = PageStore(loadPages()) + private val processingJobs = synchronizedMap(mutableMapOf>()) private val imageCache = createLruCache>(maxEntries = 50) private val thumbnailCache = createLruCache>(maxEntries = 200) private fun createLruCache(maxEntries: Int): MutableMap = - Collections.synchronizedMap(object : LinkedHashMap(16, 0.75f, true) { + synchronizedMap(object : LinkedHashMap(16, 0.75f, true) { override fun removeEldestEntry(eldest: Map.Entry) = size > maxEntries }) @@ -91,7 +92,7 @@ class ImageRepository( return when { metadataPages != null -> metadataPages - .filter { "${it.id}.jpg" in filesOnDisk } + .filter { processedImageFileName(it.id, it.colorMode) in filesOnDisk } .toMutableList() else -> filesOnDisk @@ -135,7 +136,7 @@ class ImageRepository( pages.pages().mapNotNull { runCatching { val manualRotation = Rotation.fromDegrees(it.manualRotationDegrees) - ScanPage(it.id, manualRotation, it.toMetadata()) + ScanPage(it.id, manualRotation, it.colorMode, it.toMetadata()) }.getOrNull() } } @@ -143,24 +144,53 @@ class ImageRepository( suspend fun add(processed: Jpeg, source: Jpeg, metadata: PageMetadata) = mutex.withLock { val id = "${System.currentTimeMillis()}" - val fileName = "$id.jpg" - File(processedDir, fileName).writeBytes(processed.bytes) - File(sourceDir, fileName).writeBytes(source.bytes) + val key = PageViewKey(id, Rotation.R0, metadata.autoColorMode) + processedImageFile(key).writeBytes(processed.bytes) + sourceFile(id).writeBytes(source.bytes) pages.addOrReplace( PageV2( id = id, quad = metadata.normalizedQuad.toSerializable(), baseRotationDegrees = metadata.baseRotation.degrees, manualRotationDegrees = Rotation.R0.degrees, - isColored = metadata.autoColorMode == ColorMode.COLOR + isColored = metadata.autoColorMode == ColorMode.COLOR, + colorMode = metadata.autoColorMode, ) ) saveMetadata() // Pre-populate cache for R0 - val key = PageViewKey(id, Rotation.R0) 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 { val page = pages.get(id) ?: return@withLock val delta = if (clockwise) Rotation.R90 else Rotation.R270 @@ -198,7 +228,7 @@ class ImageRepository( private suspend fun computeProcessedImage(key: PageViewKey): Jpeg? = withContext(Dispatchers.IO) { - val baseFile = File(processedDir, "${key.pageId}.jpg") + val baseFile = processedImageFile(key) if (!baseFile.exists()) return@withContext null val baseJpeg = Jpeg(baseFile.readBytes()) if (key.rotation == Rotation.R0) { @@ -220,8 +250,20 @@ class ImageRepository( // --- 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? { - val file = File(sourceDir, "$id.jpg") + val file = sourceFile(id) return if (file.exists()) Jpeg(file.readBytes()) else null } @@ -233,7 +275,7 @@ class ImageRepository( suspend fun delete(id: String) = mutex.withLock { pages.delete(id) saveMetadata() - File(sourceDir, "$id.jpg").delete() + sourceFile(id).delete() processedDir.listFiles() ?.filter { it.name.startsWith("$id.") || it.name.startsWith("$id-") } ?.forEach { it.delete() } diff --git a/app/src/main/java/org/fairscan/app/data/ImageTransformations.kt b/app/src/main/java/org/fairscan/app/data/ImageTransformations.kt index bff485a..e32ede5 100644 --- a/app/src/main/java/org/fairscan/app/data/ImageTransformations.kt +++ b/app/src/main/java/org/fairscan/app/data/ImageTransformations.kt @@ -15,6 +15,8 @@ package org.fairscan.app.data import org.fairscan.app.domain.Jpeg +import org.fairscan.app.domain.PageMetadata +import org.fairscan.imageprocessing.ColorMode interface ImageTransformations { @@ -22,4 +24,6 @@ interface ImageTransformations { fun resize(input: Jpeg, maxSize: Int): Jpeg + fun process(source: Jpeg, metadata: PageMetadata, colorMode: ColorMode): Jpeg + } \ No newline at end of file diff --git a/app/src/main/java/org/fairscan/app/domain/ExportPreparation.kt b/app/src/main/java/org/fairscan/app/domain/ExportPreparation.kt index 3791b01..a567365 100644 --- a/app/src/main/java/org/fairscan/app/domain/ExportPreparation.kt +++ b/app/src/main/java/org/fairscan/app/domain/ExportPreparation.kt @@ -15,6 +15,7 @@ package org.fairscan.app.domain import org.fairscan.app.data.ImageRepository +import org.fairscan.imageprocessing.ColorMode import org.fairscan.imageprocessing.extractDocument import org.fairscan.imageprocessing.resizeForMaxPixels import org.fairscan.imageprocessing.scaledTo @@ -48,10 +49,11 @@ suspend fun jpegsForExport( ExportQuality.HIGH -> pages.map { page -> JpegProvider { val source = imageRepository.source(page.id) - val pageMetadata = page.metadata + val metadata = page.metadata val manualRotation = page.manualRotation - if (source != null && pageMetadata != null) - prepareJpegForHigh(source, pageMetadata, manualRotation, exportQuality) + val colorMode = page.colorMode + if (source != null && metadata != null && colorMode != null) + prepareJpegForHigh(source, metadata, manualRotation, colorMode, exportQuality) else jpeg(page, imageRepository) } @@ -86,6 +88,7 @@ private fun prepareJpegForHigh( source: Jpeg, pageMetadata: PageMetadata, manualRotation: Rotation, + colorMode: ColorMode, exportQuality: ExportQuality, ): Jpeg { @@ -94,13 +97,8 @@ private fun prepareJpegForHigh( try { decoded = source.toMat() val quad = pageMetadata.normalizedQuad.scaledTo(1, 1, decoded.width(), decoded.height()) - page = extractDocument( - decoded, - quad, - pageMetadata.baseRotation.add(manualRotation).degrees, - pageMetadata.autoColorMode, - exportQuality.maxPixels - ) + val rotationDegrees = pageMetadata.baseRotation.add(manualRotation).degrees + page = extractDocument(decoded, quad, rotationDegrees, colorMode, exportQuality.maxPixels) return Jpeg.fromMat(page, exportQuality.jpegQuality) } finally { decoded?.release() diff --git a/app/src/main/java/org/fairscan/app/domain/Page.kt b/app/src/main/java/org/fairscan/app/domain/Page.kt index 80b1843..6042e9b 100644 --- a/app/src/main/java/org/fairscan/app/domain/Page.kt +++ b/app/src/main/java/org/fairscan/app/domain/Page.kt @@ -26,16 +26,18 @@ data class PageMetadata( data class ScanPage( val id: String, val manualRotation: Rotation, + val colorMode: ColorMode?, val metadata: PageMetadata?, ) { - fun key(): PageViewKey = PageViewKey(id, manualRotation) + fun key(): PageViewKey = PageViewKey(id, manualRotation, colorMode) } data class PageViewKey( val pageId: String, 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) { diff --git a/app/src/main/java/org/fairscan/app/platform/OpenCvImageTransformations.kt b/app/src/main/java/org/fairscan/app/platform/OpenCvImageTransformations.kt index a456fc7..3ed0270 100644 --- a/app/src/main/java/org/fairscan/app/platform/OpenCvImageTransformations.kt +++ b/app/src/main/java/org/fairscan/app/platform/OpenCvImageTransformations.kt @@ -15,7 +15,12 @@ package org.fairscan.app.platform import org.fairscan.app.data.ImageTransformations +import org.fairscan.app.domain.ExportQuality 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.Size import org.opencv.imgproc.Imgproc @@ -59,4 +64,30 @@ class OpenCvTransformations : ImageTransformations { 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() + } + } } diff --git a/app/src/main/java/org/fairscan/app/ui/PreviewUtils.kt b/app/src/main/java/org/fairscan/app/ui/PreviewUtils.kt index 68632a0..0fc6e41 100644 --- a/app/src/main/java/org/fairscan/app/ui/PreviewUtils.kt +++ b/app/src/main/java/org/fairscan/app/ui/PreviewUtils.kt @@ -24,6 +24,7 @@ import org.fairscan.app.domain.PageViewKey import org.fairscan.app.domain.Rotation import org.fairscan.app.ui.state.DocumentUiModel import org.fairscan.app.ui.state.PageThumbnail +import org.fairscan.imageprocessing.ColorMode fun dummyNavigation(): Navigation { return Navigation({}, {}, {}, {}, {}, {}, {}, {}) @@ -35,7 +36,7 @@ fun fakeDocument(): DocumentUiModel { fun fakeDocument(pageIds: ImmutableList, context: Context): DocumentUiModel { val pageKeys = pageIds.map { - PageThumbnail(PageViewKey(it, Rotation.R0), fakeImage(it, context)) + PageThumbnail(PageViewKey(it, Rotation.R0, ColorMode.COLOR), fakeImage(it, context)) }.toImmutableList() return DocumentUiModel(pageKeys) } diff --git a/app/src/main/java/org/fairscan/app/ui/screens/document/DocumentScreen.kt b/app/src/main/java/org/fairscan/app/ui/screens/document/DocumentScreen.kt index ee22811..24c2cb0 100644 --- a/app/src/main/java/org/fairscan/app/ui/screens/document/DocumentScreen.kt +++ b/app/src/main/java/org/fairscan/app/ui/screens/document/DocumentScreen.kt @@ -31,7 +31,9 @@ import androidx.compose.foundation.layout.width import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.shape.RoundedCornerShape 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.Palette import androidx.compose.material.icons.filled.RotateLeft import androidx.compose.material.icons.filled.RotateRight 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.fakeImage 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) @Composable @@ -83,6 +88,7 @@ fun DocumentScreen( onExportClick: () -> Unit, onDeleteImage: (String) -> Unit, onRotateImage: (String, Boolean) -> Unit, + onToggleColorMode: (String) -> Unit, onPageReorder: (String, Int) -> Unit, onPageSelected: (Int) -> Unit, ) { @@ -116,6 +122,7 @@ fun DocumentScreen( uiState, { showDeletePageDialog.value = true }, onRotateImage, + onToggleColorMode, modifier ) if (showDeletePageDialog.value) { @@ -133,6 +140,7 @@ private fun DocumentPreview( uiState: DocumentUiState, onDeleteImage: (String) -> Unit, onRotateImage: (String, Boolean) -> Unit, + onToggleColorMode: (String) -> Unit, modifier: Modifier, ) { val currentPageIndex = uiState.currentPageIndex @@ -145,7 +153,7 @@ private fun DocumentPreview( Box ( modifier = Modifier.fillMaxSize() ) { - val bitmap = uiState.currentPageBitmap + val bitmap = uiState.currentPage.bitmap if (bitmap != null) { val imageBitmap = bitmap.asImageBitmap() 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)) SecondaryActionButton( Icons.Outlined.Delete, @@ -179,10 +196,10 @@ private fun DocumentPreview( Text("${currentPageIndex + 1} / ${document.pageCount()}", color = MaterialTheme.colorScheme.inverseOnSurface, modifier = Modifier - .align(Alignment.BottomStart) + .align(Alignment.TopCenter) .padding(all = 16.dp) .background( - color = MaterialTheme.colorScheme.inverseSurface.copy(alpha = 0.5f), + color = MaterialTheme.colorScheme.inverseSurface.copy(alpha = 0.7f), shape = RoundedCornerShape(4.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 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 @Suppress("DEPRECATION") 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 private fun BottomBar( onExportClick: () -> Unit, @@ -264,11 +307,12 @@ fun DocumentScreenPreview() { LocalContext.current ) DocumentScreen( - uiState = DocumentUiState(1, image, document), + uiState = DocumentUiState(1, CurrentPageUiState(image, COLOR), document), navigation = dummyNavigation(), onExportClick = {}, onDeleteImage = { _ -> }, onRotateImage = { _,_ -> }, + onToggleColorMode = { _ -> }, onPageReorder = { _,_ -> }, onPageSelected = { _ -> }, ) diff --git a/app/src/main/java/org/fairscan/app/ui/screens/document/DocumentUiState.kt b/app/src/main/java/org/fairscan/app/ui/screens/document/DocumentUiState.kt index 95a5fb4..7c5ea6d 100644 --- a/app/src/main/java/org/fairscan/app/ui/screens/document/DocumentUiState.kt +++ b/app/src/main/java/org/fairscan/app/ui/screens/document/DocumentUiState.kt @@ -16,9 +16,15 @@ package org.fairscan.app.ui.screens.document import android.graphics.Bitmap import org.fairscan.app.ui.state.DocumentUiModel +import org.fairscan.imageprocessing.ColorMode data class DocumentUiState( val currentPageIndex: Int, - val currentPageBitmap: Bitmap?, + val currentPage: CurrentPageUiState, val document: DocumentUiModel, ) + +data class CurrentPageUiState( + val bitmap: Bitmap? = null, + val colorMode: ColorMode? = null, +) diff --git a/app/src/main/java/org/fairscan/app/ui/state/DocumentUiModel.kt b/app/src/main/java/org/fairscan/app/ui/state/DocumentUiModel.kt index ae25fc7..93a69e3 100644 --- a/app/src/main/java/org/fairscan/app/ui/state/DocumentUiModel.kt +++ b/app/src/main/java/org/fairscan/app/ui/state/DocumentUiModel.kt @@ -16,10 +16,11 @@ package org.fairscan.app.ui.state import android.graphics.Bitmap import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.persistentListOf import org.fairscan.app.domain.PageViewKey data class DocumentUiModel( - val pages: ImmutableList, + val pages: ImmutableList = persistentListOf(), ) { fun pageCount(): Int { return pages.size diff --git a/app/src/main/res/values-ar/strings.xml b/app/src/main/res/values-ar/strings.xml index e4b6d7d..bb3fb5f 100644 --- a/app/src/main/res/values-ar/strings.xml +++ b/app/src/main/res/values-ar/strings.xml @@ -8,6 +8,8 @@ ألغِ غيّر المجلد امحُ النص + ألوان + تدرج الرمادي تواصل نُسخت السجلات إلى الحافظة انسخ السجلات diff --git a/app/src/main/res/values-cs/strings.xml b/app/src/main/res/values-cs/strings.xml index 017d42c..6511244 100644 --- a/app/src/main/res/values-cs/strings.xml +++ b/app/src/main/res/values-cs/strings.xml @@ -8,6 +8,8 @@ Zrušit Změnit složku Smazat text + Barva + Odstíny šedi Kontakt Protokoly zkopírovány do schránky Kopírovat protokoly diff --git a/app/src/main/res/values-de/strings.xml b/app/src/main/res/values-de/strings.xml index ce4740b..c8c4284 100644 --- a/app/src/main/res/values-de/strings.xml +++ b/app/src/main/res/values-de/strings.xml @@ -8,6 +8,8 @@ Abbrechen Ordner ändern Text löschen + Farbe + Graustufen Kontakt Logs in die Zwischenablage kopiert Logs kopieren diff --git a/app/src/main/res/values-es/strings.xml b/app/src/main/res/values-es/strings.xml index a66e1cd..df232b7 100644 --- a/app/src/main/res/values-es/strings.xml +++ b/app/src/main/res/values-es/strings.xml @@ -8,6 +8,8 @@ Cancelar Cambiar carpeta Borrar texto + Color + Escala de grises Contacto Registros copiados al portapapeles Copiar registros diff --git a/app/src/main/res/values-fr/strings.xml b/app/src/main/res/values-fr/strings.xml index 8989c2a..2da31e7 100644 --- a/app/src/main/res/values-fr/strings.xml +++ b/app/src/main/res/values-fr/strings.xml @@ -8,6 +8,8 @@ Annuler Changer de dossier Effacer le text + Couleur + Niveaux de gris Contact Logs copiés dans le presse-papiers Copier les logs diff --git a/app/src/main/res/values-gl/strings.xml b/app/src/main/res/values-gl/strings.xml index c782b04..7141517 100644 --- a/app/src/main/res/values-gl/strings.xml +++ b/app/src/main/res/values-gl/strings.xml @@ -8,6 +8,8 @@ Cancelar Trocar cartafol Borrar texto + Cor + Escala de grises Contacto Rexistros copiados ao portapapeis Copiar rexistros diff --git a/app/src/main/res/values-it/strings.xml b/app/src/main/res/values-it/strings.xml index 79572dd..4325708 100644 --- a/app/src/main/res/values-it/strings.xml +++ b/app/src/main/res/values-it/strings.xml @@ -8,6 +8,8 @@ Annulla Cambia cartella Svuota testo + Colore + Scala di grigi Contatti Log copiati negli appunti Copia log diff --git a/app/src/main/res/values-pt-rBR/strings.xml b/app/src/main/res/values-pt-rBR/strings.xml index 2bf844c..ebfc517 100644 --- a/app/src/main/res/values-pt-rBR/strings.xml +++ b/app/src/main/res/values-pt-rBR/strings.xml @@ -8,6 +8,8 @@ Cancelar Alterar diretório Limpar texto + Cor + Escala de cinza Contato Registros copiados para a área de transferência Copiar registros diff --git a/app/src/main/res/values-ru/strings.xml b/app/src/main/res/values-ru/strings.xml index 489ba53..b9ecbf2 100644 --- a/app/src/main/res/values-ru/strings.xml +++ b/app/src/main/res/values-ru/strings.xml @@ -8,6 +8,8 @@ Отмена Изменить папку Стереть текст + Цвет + Оттенки серого Контакты Журналы скопированы в буфер обмена Копировать журналы diff --git a/app/src/main/res/values-tr/strings.xml b/app/src/main/res/values-tr/strings.xml index 558ce89..b6c45da 100644 --- a/app/src/main/res/values-tr/strings.xml +++ b/app/src/main/res/values-tr/strings.xml @@ -8,6 +8,8 @@ İptal Dizini değiştir Metni temizle + Renkli + Gri tonlama İletişim Günlükler panoya kopyalandı Günlükleri kopyala diff --git a/app/src/main/res/values-zh-rTW/strings.xml b/app/src/main/res/values-zh-rTW/strings.xml index 132abf6..40cdaab 100644 --- a/app/src/main/res/values-zh-rTW/strings.xml +++ b/app/src/main/res/values-zh-rTW/strings.xml @@ -8,6 +8,8 @@ 取消 變更目錄 清除文字 + 彩色 + 灰階 聯絡我們 日誌已複製到剪貼簿 複製日誌 diff --git a/app/src/main/res/values-zh/strings.xml b/app/src/main/res/values-zh/strings.xml index f4b93f3..5a84a35 100644 --- a/app/src/main/res/values-zh/strings.xml +++ b/app/src/main/res/values-zh/strings.xml @@ -8,6 +8,8 @@ 取消 更改目录 清除文字 + 彩色 + 灰度 联系人 日志已复制到剪贴板 复制日志 diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index a99818b..90280e5 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -9,6 +9,8 @@ Cancel Change folder Clear text + Color + Grayscale Contact Logs copied to clipboard Copy logs diff --git a/app/src/test/java/org/fairscan/app/data/ImageRepositoryTest.kt b/app/src/test/java/org/fairscan/app/data/ImageRepositoryTest.kt index 4c9d636..4b010bc 100644 --- a/app/src/test/java/org/fairscan/app/data/ImageRepositoryTest.kt +++ b/app/src/test/java/org/fairscan/app/data/ImageRepositoryTest.kt @@ -27,6 +27,7 @@ import org.fairscan.app.domain.Rotation.R180 import org.fairscan.app.domain.Rotation.R270 import org.fairscan.app.domain.Rotation.R90 import org.fairscan.imageprocessing.ColorMode +import org.fairscan.imageprocessing.ColorMode.COLOR import org.fairscan.imageprocessing.Point import org.fairscan.imageprocessing.Quad import org.junit.Rule @@ -44,7 +45,7 @@ class ImageRepositoryTest { private val testScope = TestScope() 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 { if (_filesDir == null) { @@ -61,6 +62,10 @@ class ImageRepositoryTest { override fun resize(input: Jpeg, maxSize: Int): Jpeg { 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) } @@ -73,7 +78,7 @@ class ImageRepositoryTest { repo.add(jpeg, jpeg(51), metadata1) assertThat(repo.imageIds()).hasSize(1) val id = repo.imageIds()[0] - val key = PageViewKey(id, R0) + val key = PageViewKey(id, R0, COLOR) assertThat(repo.jpegBytes(key)).isEqualTo(jpeg) assertThat(repo.getThumbnail(key)?.bytes).isEqualTo(byteArrayOf(101)) @@ -140,7 +145,7 @@ class ImageRepositoryTest { File(processedDir(), "1-90.jpg").writeBytes(bytes) val repo = repo() 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 @@ -169,14 +174,14 @@ class ImageRepositoryTest { File(processedDir(), "1-90.jpg").writeBytes(bytes) val repo = repo() 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 fun `should return null on invalid id`() = runTest { val repo = repo() assertThat(repo.imageIds()).isEmpty() - assertThat(repo.jpegBytes(PageViewKey("x", R0))).isNull() + assertThat(repo.jpegBytes(PageViewKey("x", R0, COLOR))).isNull() } @Test @@ -255,7 +260,7 @@ class ImageRepositoryTest { val metadata = PageV2("1", 0, 0, quad, isColored).toMetadata() assertThat(metadata).isNotNull() assertThat(metadata!!.autoColorMode).isEqualTo( - if (isColored) ColorMode.COLOR else ColorMode.GRAYSCALE + if (isColored) COLOR else ColorMode.GRAYSCALE ) } }