New feature: ability to force color mode for a page
This commit is contained in:
@@ -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
|
||||
)
|
||||
|
||||
@@ -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<Int> =
|
||||
_currentPageIndex.stateIn(viewModelScope, SharingStarted.Eagerly, 0)
|
||||
@OptIn(ExperimentalCoroutinesApi::class)
|
||||
val currentPageBitmap: StateFlow<Bitmap?> =
|
||||
_currentPageIndex
|
||||
.combine(_pages) { index, pages -> pages.getOrNull(index) }
|
||||
|
||||
val currentPageUiState: Flow<CurrentPageUiState> =
|
||||
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<DocumentUiState> =
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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<PageViewKey, Deferred<Unit>>())
|
||||
private val imageCache = createLruCache<PageViewKey, Deferred<Jpeg?>>(maxEntries = 50)
|
||||
private val thumbnailCache = createLruCache<PageViewKey, Deferred<Jpeg?>>(maxEntries = 200)
|
||||
|
||||
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
|
||||
})
|
||||
|
||||
@@ -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() }
|
||||
|
||||
@@ -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
|
||||
|
||||
}
|
||||
@@ -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()
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<String>, 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)
|
||||
}
|
||||
|
||||
@@ -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 = { _ -> },
|
||||
)
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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<PageThumbnail>,
|
||||
val pages: ImmutableList<PageThumbnail> = persistentListOf(),
|
||||
) {
|
||||
fun pageCount(): Int {
|
||||
return pages.size
|
||||
|
||||
@@ -8,6 +8,8 @@
|
||||
<string name="cancel">ألغِ</string>
|
||||
<string name="change_directory">غيّر المجلد</string>
|
||||
<string name="clear_text">امحُ النص</string>
|
||||
<string name="color_mode_color">ألوان</string>
|
||||
<string name="color_mode_grayscale">تدرج الرمادي</string>
|
||||
<string name="contact">تواصل</string>
|
||||
<string name="copied_logs">نُسخت السجلات إلى الحافظة</string>
|
||||
<string name="copy_logs">انسخ السجلات</string>
|
||||
|
||||
@@ -8,6 +8,8 @@
|
||||
<string name="cancel">Zrušit</string>
|
||||
<string name="change_directory">Změnit složku</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="copied_logs">Protokoly zkopírovány do schránky</string>
|
||||
<string name="copy_logs">Kopírovat protokoly</string>
|
||||
|
||||
@@ -8,6 +8,8 @@
|
||||
<string name="cancel">Abbrechen</string>
|
||||
<string name="change_directory">Ordner ändern</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="copied_logs">Logs in die Zwischenablage kopiert</string>
|
||||
<string name="copy_logs">Logs kopieren</string>
|
||||
|
||||
@@ -8,6 +8,8 @@
|
||||
<string name="cancel">Cancelar</string>
|
||||
<string name="change_directory">Cambiar carpeta</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="copied_logs">Registros copiados al portapapeles</string>
|
||||
<string name="copy_logs">Copiar registros</string>
|
||||
|
||||
@@ -8,6 +8,8 @@
|
||||
<string name="cancel">Annuler</string>
|
||||
<string name="change_directory">Changer de dossier</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="copied_logs">Logs copiés dans le presse-papiers</string>
|
||||
<string name="copy_logs">Copier les logs</string>
|
||||
|
||||
@@ -8,6 +8,8 @@
|
||||
<string name="cancel">Cancelar</string>
|
||||
<string name="change_directory">Trocar cartafol</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="copied_logs">Rexistros copiados ao portapapeis</string>
|
||||
<string name="copy_logs">Copiar rexistros</string>
|
||||
|
||||
@@ -8,6 +8,8 @@
|
||||
<string name="cancel">Annulla</string>
|
||||
<string name="change_directory">Cambia cartella</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="copied_logs">Log copiati negli appunti</string>
|
||||
<string name="copy_logs">Copia log</string>
|
||||
|
||||
@@ -8,6 +8,8 @@
|
||||
<string name="cancel">Cancelar</string>
|
||||
<string name="change_directory">Alterar diretório</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="copied_logs">Registros copiados para a área de transferência</string>
|
||||
<string name="copy_logs">Copiar registros</string>
|
||||
|
||||
@@ -8,6 +8,8 @@
|
||||
<string name="cancel">Отмена</string>
|
||||
<string name="change_directory">Изменить папку</string>
|
||||
<string name="clear_text">Стереть текст</string>
|
||||
<string name="color_mode_color">Цвет</string>
|
||||
<string name="color_mode_grayscale">Оттенки серого</string>
|
||||
<string name="contact">Контакты</string>
|
||||
<string name="copied_logs">Журналы скопированы в буфер обмена</string>
|
||||
<string name="copy_logs">Копировать журналы</string>
|
||||
|
||||
@@ -8,6 +8,8 @@
|
||||
<string name="cancel">İptal</string>
|
||||
<string name="change_directory">Dizini değiştir</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="copied_logs">Günlükler panoya kopyalandı</string>
|
||||
<string name="copy_logs">Günlükleri kopyala</string>
|
||||
|
||||
@@ -8,6 +8,8 @@
|
||||
<string name="cancel">取消</string>
|
||||
<string name="change_directory">變更目錄</string>
|
||||
<string name="clear_text">清除文字</string>
|
||||
<string name="color_mode_color">彩色</string>
|
||||
<string name="color_mode_grayscale">灰階</string>
|
||||
<string name="contact">聯絡我們</string>
|
||||
<string name="copied_logs">日誌已複製到剪貼簿</string>
|
||||
<string name="copy_logs">複製日誌</string>
|
||||
|
||||
@@ -8,6 +8,8 @@
|
||||
<string name="cancel">取消</string>
|
||||
<string name="change_directory">更改目录</string>
|
||||
<string name="clear_text">清除文字</string>
|
||||
<string name="color_mode_color">彩色</string>
|
||||
<string name="color_mode_grayscale">灰度</string>
|
||||
<string name="contact">联系人</string>
|
||||
<string name="copied_logs">日志已复制到剪贴板</string>
|
||||
<string name="copy_logs">复制日志</string>
|
||||
|
||||
@@ -9,6 +9,8 @@
|
||||
<string name="cancel">Cancel</string>
|
||||
<string name="change_directory">Change folder</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="copied_logs">Logs copied to clipboard</string>
|
||||
<string name="copy_logs">Copy logs</string>
|
||||
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user