From 551589b713b6234185634f1215add08083229827 Mon Sep 17 00:00:00 2001 From: Christian Nagel Date: Fri, 20 Feb 2026 11:00:22 +0100 Subject: [PATCH 01/16] ScanSettings view model refactor to be independent of Compose --- .../github/chrisimx/scanbridge/CropScreen.kt | 3 +- .../chrisimx/scanbridge/ScanSettings.kt | 9 +- .../data/model/ESCLScanSettingsState.kt | 312 ------------------ .../data/model/LegacyESCLScanSettings.kt | 117 +++++++ .../scanbridge/data/model/ScanRegionState.kt | 99 ------ .../chrisimx/scanbridge/data/model/Session.kt | 24 +- .../data/ui/ScanSettingsComposableData.kt | 117 +------ .../ui/ScanSettingsComposableViewModel.kt | 220 ++++++++---- .../data/ui/ScanningScreenViewModel.kt | 4 +- .../scanbridge/stores/SessionsStore.kt | 5 +- .../scanbridge/util/ESCLKtExtensions.kt | 52 ++- .../scanbridge/util/StateFlowExtensions.kt | 17 + 12 files changed, 350 insertions(+), 629 deletions(-) delete mode 100644 app/src/main/java/io/github/chrisimx/scanbridge/data/model/ESCLScanSettingsState.kt create mode 100644 app/src/main/java/io/github/chrisimx/scanbridge/data/model/LegacyESCLScanSettings.kt delete mode 100644 app/src/main/java/io/github/chrisimx/scanbridge/data/model/ScanRegionState.kt create mode 100644 app/src/main/java/io/github/chrisimx/scanbridge/util/StateFlowExtensions.kt diff --git a/app/src/main/java/io/github/chrisimx/scanbridge/CropScreen.kt b/app/src/main/java/io/github/chrisimx/scanbridge/CropScreen.kt index fbd9f98..a75000e 100644 --- a/app/src/main/java/io/github/chrisimx/scanbridge/CropScreen.kt +++ b/app/src/main/java/io/github/chrisimx/scanbridge/CropScreen.kt @@ -100,7 +100,8 @@ fun CropScreen(sessionID: String, pageIdx: Int, returnRoute: BaseRoute, navContr var processing: Boolean by remember { mutableStateOf(false) } val originalSessionResult: Result = remember { - SessionsStore.loadSession(context, sessionID) + // We don't need to load scan regions so we can set caps to null: + SessionsStore.loadSession(context, sessionID, null) } if (originalSessionResult.getOrNull() == null) { diff --git a/app/src/main/java/io/github/chrisimx/scanbridge/ScanSettings.kt b/app/src/main/java/io/github/chrisimx/scanbridge/ScanSettings.kt index 644d74f..293ec53 100644 --- a/app/src/main/java/io/github/chrisimx/scanbridge/ScanSettings.kt +++ b/app/src/main/java/io/github/chrisimx/scanbridge/ScanSettings.kt @@ -45,6 +45,7 @@ import androidx.compose.material3.SingleChoiceSegmentedButtonRow import androidx.compose.material3.Text import androidx.compose.material3.ToggleButton import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember @@ -74,9 +75,7 @@ private val TAG = "ScanSettings" @OptIn(ExperimentalLayoutApi::class, ExperimentalMaterial3ExpressiveApi::class) @Composable fun ScanSettingsUI(modifier: Modifier, context: Context, scanSettingsViewModel: ScanSettingsComposableViewModel = viewModel()) { - val scanSettingsUIState = scanSettingsViewModel.scanSettingsComposableData - - assert(scanSettingsUIState.inputSourceOptions.isNotEmpty()) // The settings are useless if this is the case + val vmData = scanSettingsViewModel.uiState.collectAsState() val scrollState = rememberScrollState() @@ -227,7 +226,7 @@ fun ScanSettingsUI(modifier: Modifier, context: Context, scanSettingsViewModel: .padding(end = 10.dp), stringResource(R.string.width_in_mm), { newText: String -> - scanSettingsViewModel.setWidthTextFieldContent( + scanSettingsViewModel.setCustomWidthTextFieldContent( newText ) }, @@ -247,7 +246,7 @@ fun ScanSettingsUI(modifier: Modifier, context: Context, scanSettingsViewModel: .weight(1f) .padding(start = 10.dp), stringResource(R.string.height_in_mm), - { scanSettingsViewModel.setHeightTextFieldContent(it) }, + { scanSettingsViewModel.setCustomHeightTextFieldContent(it) }, { scanSettingsViewModel.setRegionDimension( scanSettingsUIState.widthTextFieldString, diff --git a/app/src/main/java/io/github/chrisimx/scanbridge/data/model/ESCLScanSettingsState.kt b/app/src/main/java/io/github/chrisimx/scanbridge/data/model/ESCLScanSettingsState.kt deleted file mode 100644 index 5eb7b17..0000000 --- a/app/src/main/java/io/github/chrisimx/scanbridge/data/model/ESCLScanSettingsState.kt +++ /dev/null @@ -1,312 +0,0 @@ -package io.github.chrisimx.scanbridge.data.model - -import androidx.compose.runtime.MutableState -import androidx.compose.runtime.State -import androidx.compose.runtime.derivedStateOf -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.setValue -import io.github.chrisimx.esclkt.BinaryRendering -import io.github.chrisimx.esclkt.CcdChannelEnumOrRaw -import io.github.chrisimx.esclkt.ColorModeEnumOrRaw -import io.github.chrisimx.esclkt.ContentTypeEnumOrRaw -import io.github.chrisimx.esclkt.FeedDirection -import io.github.chrisimx.esclkt.InputSource -import io.github.chrisimx.esclkt.InputSourceCaps -import io.github.chrisimx.esclkt.ScanIntentEnumOrRaw -import io.github.chrisimx.esclkt.ScanRegions -import io.github.chrisimx.esclkt.ScanSettings -import kotlinx.serialization.Serializable - -@Serializable -data class MutableESCLScanSettingsState( - /** Specified in DPI **/ - private var xResolutionState: MutableState, - /** Specified in DPI **/ - private var yResolutionState: MutableState, - private var versionState: MutableState, - private var intentState: MutableState = mutableStateOf(null), - private var scanRegionsState: MutableState = mutableStateOf(null), - private var documentFormatExtState: MutableState = mutableStateOf(null), - private var contentTypeState: MutableState = mutableStateOf(null), - private var inputSourceState: MutableState = mutableStateOf(null), - private var colorModeState: MutableState = mutableStateOf(null), - private var colorSpaceState: MutableState = mutableStateOf(null), - private var mediaTypeState: MutableState = mutableStateOf(null), - private var ccdChannelState: MutableState = mutableStateOf(null), - private var binaryRenderingState: MutableState = mutableStateOf(null), - private var duplexState: MutableState = mutableStateOf(null), - private var numberOfPagesState: MutableState = mutableStateOf(null), - private var brightnessState: MutableState = mutableStateOf(null), - private var compressionFactorState: MutableState = mutableStateOf(null), - private var contrastState: MutableState = mutableStateOf(null), - private var gammaState: MutableState = mutableStateOf(null), - private var highlightState: MutableState = mutableStateOf(null), - private var noiseRemovalState: MutableState = mutableStateOf(null), - private var shadowState: MutableState = mutableStateOf(null), - private var sharpenState: MutableState = mutableStateOf(null), - private var thresholdState: MutableState = mutableStateOf(null), - /** As per spec: "opaque information relayed by the client." **/ - private var contextIDState: MutableState = mutableStateOf(null), - // private var scanDestinationsState: HTTPDestination?, omitted as no known scanner supports this - private var blankPageDetectionState: MutableState = mutableStateOf(null), - private var feedDirectionState: MutableState = mutableStateOf(null), - private var blankPageDetectionAndRemovalState: MutableState = mutableStateOf(null) -) { - var version by versionState - var intent by intentState - var scanRegions by scanRegionsState - var documentFormatExt by documentFormatExtState - var contentType by contentTypeState - var inputSource by inputSourceState - var xResolution by xResolutionState - var yResolution by yResolutionState - var colorMode by colorModeState - var colorSpace by colorSpaceState - var mediaType by mediaTypeState - var ccdChannel by ccdChannelState - var binaryRendering by binaryRenderingState - var duplex by duplexState - var numberOfPages by numberOfPagesState - var brightness by brightnessState - var compressionFactor by compressionFactorState - var contrast by contrastState - var gamma by gammaState - var highlight by highlightState - var noiseRemoval by noiseRemovalState - var shadow by shadowState - var sharpen by sharpenState - var threshold by thresholdState - var contextID by contextIDState - var blankPageDetection by blankPageDetectionState - var feedDirection by feedDirectionState - var blankPageDetectionAndRemoval by blankPageDetectionAndRemovalState - - fun toImmutable(): ImmutableESCLScanSettingsState = ImmutableESCLScanSettingsState( - versionState, - intentState, - derivedStateOf { scanRegionsState.value?.toImmutable() }, - documentFormatExtState, - contentTypeState, - inputSourceState, - xResolutionState, - yResolutionState, - colorModeState, - colorSpaceState, - mediaTypeState, - ccdChannelState, - binaryRenderingState, - duplexState, - numberOfPagesState, - brightnessState, - compressionFactorState, - contrastState, - gammaState, - highlightState, - noiseRemovalState, - shadowState, - sharpenState, - thresholdState, - contextIDState, - blankPageDetectionState, - feedDirectionState, - blankPageDetectionAndRemovalState - ) - - fun toStateless(): StatelessImmutableESCLScanSettingsState = StatelessImmutableESCLScanSettingsState( - versionState.value, - intentState.value, - scanRegionsState.value?.toStateless(), - documentFormatExtState.value, - contentTypeState.value, - inputSourceState.value, - xResolutionState.value, - yResolutionState.value, - colorModeState.value, - colorSpaceState.value, - mediaTypeState.value, - ccdChannelState.value, - binaryRenderingState.value, - duplexState.value, - numberOfPagesState.value, - brightnessState.value, - compressionFactorState.value, - contrastState.value, - gammaState.value, - highlightState.value, - noiseRemovalState.value, - shadowState.value, - sharpenState.value, - thresholdState.value, - contextIDState.value, - blankPageDetectionState.value, - feedDirectionState.value, - blankPageDetectionAndRemovalState.value - ) - - fun toESCLKtScanSettings(selectedInputSourceCaps: InputSourceCaps): ScanSettings = - toImmutable().toESCLKtScanSettings(selectedInputSourceCaps) -} - -@Serializable -data class ImmutableESCLScanSettingsState( - val versionState: State, - val intentState: State, - val scanRegionsState: State, - val documentFormatExtState: State, - val contentTypeState: State, - val inputSourceState: State, - val xResolutionState: State, - val yResolutionState: State, - val colorModeState: State, - val colorSpaceState: State, - val mediaTypeState: State, - val ccdChannelState: State, - val binaryRenderingState: State, - val duplexState: State, - val numberOfPagesState: State, - val brightnessState: State, - val compressionFactorState: State, - val contrastState: State, - val gammaState: State, - val highlightState: State, - val noiseRemovalState: State, - val shadowState: State, - val sharpenState: State, - val thresholdState: State, - val contextIDState: State, - val blankPageDetectionState: State, - val feedDirectionState: State, - val blankPageDetectionAndRemovalState: State -) { - // Declare properties with only a getter - val version by versionState - val intent by intentState - val scanRegions by scanRegionsState - val documentFormatExt by documentFormatExtState - val contentType by contentTypeState - val inputSource by inputSourceState - val xResolution by xResolutionState - val yResolution by yResolutionState - val colorMode by colorModeState - val colorSpace by colorSpaceState - val mediaType by mediaTypeState - val ccdChannel by ccdChannelState - val binaryRendering by binaryRenderingState - val duplex by duplexState - val numberOfPages by numberOfPagesState - val brightness by brightnessState - val compressionFactor by compressionFactorState - val contrast by contrastState - val gamma by gammaState - val highlight by highlightState - val noiseRemoval by noiseRemovalState - val shadow by shadowState - val sharpen by sharpenState - val threshold by thresholdState - val contextID by contextIDState - val blankPageDetection by blankPageDetectionState - val feedDirection by feedDirectionState - val blankPageDetectionAndRemoval by blankPageDetectionAndRemovalState - - fun toESCLKtScanSettings(selectedInputSourceCaps: InputSourceCaps): ScanSettings { - val scanRegionsESCL = if (scanRegions != null) { - listOf(scanRegions!!.toESCLScanRegion(selectedInputSourceCaps)) - } else { - emptyList() - } - return ScanSettings( - version = version, - intent = intent, - scanRegions = ScanRegions(scanRegionsESCL), - documentFormatExt = documentFormatExt, - contentType = contentType, - inputSource = inputSource, - xResolution = xResolution, - yResolution = yResolution, - colorMode = colorMode, - colorSpace = colorSpace, - mediaType = mediaType, - ccdChannel = ccdChannel, - binaryRendering = binaryRendering, - duplex = duplex, - numberOfPages = numberOfPages, - brightness = brightness, - compressionFactor = compressionFactor, - contrast = contrast, - gamma = gamma, - highlight = highlight, - noiseRemoval = noiseRemoval, - shadow = shadow, - sharpen = sharpen, - threshold = threshold, - contextID = contextID, - blankPageDetection = blankPageDetection, - feedDirection = feedDirection, - blankPageDetectionAndRemoval = blankPageDetectionAndRemoval - ) - } -} - -@Serializable -data class StatelessImmutableESCLScanSettingsState( - val version: String, - val intent: ScanIntentEnumOrRaw?, - val scanRegions: StatelessImmutableScanRegion?, - val documentFormatExt: String?, - val contentType: ContentTypeEnumOrRaw?, - val inputSource: InputSource?, - val xResolution: UInt, - val yResolution: UInt, - val colorMode: ColorModeEnumOrRaw?, - val colorSpace: String?, - val mediaType: String?, - val ccdChannel: CcdChannelEnumOrRaw?, - val binaryRendering: BinaryRendering?, - val duplex: Boolean?, - val numberOfPages: UInt?, - val brightness: UInt?, - val compressionFactor: UInt?, - val contrast: UInt?, - val gamma: UInt?, - val highlight: UInt?, - val noiseRemoval: UInt?, - val shadow: UInt?, - val sharpen: UInt?, - val threshold: UInt?, - val contextID: String?, - val blankPageDetection: Boolean?, - val feedDirection: FeedDirection?, - val blankPageDetectionAndRemoval: Boolean? -) { - fun toMutable(): MutableESCLScanSettingsState = MutableESCLScanSettingsState( - mutableStateOf(xResolution), - mutableStateOf(yResolution), - mutableStateOf(version), - mutableStateOf(intent), - mutableStateOf(scanRegions?.toMutable()), - mutableStateOf(documentFormatExt), - mutableStateOf(contentType), - mutableStateOf(inputSource), - mutableStateOf(colorMode), - mutableStateOf(colorSpace), - mutableStateOf(mediaType), - mutableStateOf(ccdChannel), - mutableStateOf(binaryRendering), - mutableStateOf(duplex), - mutableStateOf(numberOfPages), - mutableStateOf(brightness), - mutableStateOf(compressionFactor), - mutableStateOf(contrast), - mutableStateOf(gamma), - mutableStateOf(highlight), - mutableStateOf(noiseRemoval), - mutableStateOf(shadow), - mutableStateOf(sharpen), - mutableStateOf(threshold), - mutableStateOf(contextID), - mutableStateOf(blankPageDetection), - mutableStateOf(feedDirection), - mutableStateOf(blankPageDetectionAndRemoval) - ) -} diff --git a/app/src/main/java/io/github/chrisimx/scanbridge/data/model/LegacyESCLScanSettings.kt b/app/src/main/java/io/github/chrisimx/scanbridge/data/model/LegacyESCLScanSettings.kt new file mode 100644 index 0000000..493ac94 --- /dev/null +++ b/app/src/main/java/io/github/chrisimx/scanbridge/data/model/LegacyESCLScanSettings.kt @@ -0,0 +1,117 @@ +package io.github.chrisimx.scanbridge.data.model + +import io.github.chrisimx.esclkt.BinaryRendering +import io.github.chrisimx.esclkt.CcdChannelEnumOrRaw +import io.github.chrisimx.esclkt.ColorModeEnumOrRaw +import io.github.chrisimx.esclkt.ContentTypeEnumOrRaw +import io.github.chrisimx.esclkt.FeedDirection +import io.github.chrisimx.esclkt.InputSource +import io.github.chrisimx.esclkt.InputSourceCaps +import io.github.chrisimx.esclkt.LengthUnit +import io.github.chrisimx.esclkt.ScanIntentEnumOrRaw +import io.github.chrisimx.esclkt.ScanRegion +import io.github.chrisimx.esclkt.ScanRegions +import io.github.chrisimx.esclkt.ScanSettings +import io.github.chrisimx.esclkt.millimeters +import io.github.chrisimx.scanbridge.util.toDoubleLocalized +import kotlinx.serialization.Serializable + + +@Serializable +data class StatelessImmutableScanRegion( + // These are to be given in millimeters! + val height: String, + val width: String, + val xOffset: String, + val yOffset: String +) { + + fun toESCLScanRegion(selectedInputSourceCaps: InputSourceCaps): ScanRegion { + val height: LengthUnit = when (height) { + "max" -> selectedInputSourceCaps.maxHeight + else -> height.toDoubleLocalized().millimeters() + } + val width: LengthUnit = when (width) { + "max" -> selectedInputSourceCaps.maxWidth + else -> width.toDoubleLocalized().millimeters() + } + + return ScanRegion( + height.toThreeHundredthsOfInch(), + width.toThreeHundredthsOfInch(), + xOffset.toDoubleLocalized().millimeters().toThreeHundredthsOfInch(), + yOffset.toDoubleLocalized().millimeters().toThreeHundredthsOfInch() + ) + } + +} + +@Serializable +data class StatelessImmutableESCLScanSettingsState( + val version: String, + val intent: ScanIntentEnumOrRaw?, + val scanRegions: StatelessImmutableScanRegion?, + val documentFormatExt: String?, + val contentType: ContentTypeEnumOrRaw?, + val inputSource: InputSource?, + val xResolution: UInt, + val yResolution: UInt, + val colorMode: ColorModeEnumOrRaw?, + val colorSpace: String?, + val mediaType: String?, + val ccdChannel: CcdChannelEnumOrRaw?, + val binaryRendering: BinaryRendering?, + val duplex: Boolean?, + val numberOfPages: UInt?, + val brightness: UInt?, + val compressionFactor: UInt?, + val contrast: UInt?, + val gamma: UInt?, + val highlight: UInt?, + val noiseRemoval: UInt?, + val shadow: UInt?, + val sharpen: UInt?, + val threshold: UInt?, + val contextID: String?, + val blankPageDetection: Boolean?, + val feedDirection: FeedDirection?, + val blankPageDetectionAndRemoval: Boolean? +) { + fun toESCLKtScanSettings(selectedInputSourceCaps: InputSourceCaps?): ScanSettings { + val scanRegionsESCL = if (scanRegions != null && selectedInputSourceCaps != null) { + listOf(scanRegions.toESCLScanRegion(selectedInputSourceCaps)) + } else { + emptyList() + } + return ScanSettings( + version = version, + intent = intent, + scanRegions = ScanRegions(scanRegionsESCL), + documentFormatExt = documentFormatExt, + contentType = contentType, + inputSource = inputSource, + xResolution = xResolution, + yResolution = yResolution, + colorMode = colorMode, + colorSpace = colorSpace, + mediaType = mediaType, + ccdChannel = ccdChannel, + binaryRendering = binaryRendering, + duplex = duplex, + numberOfPages = numberOfPages, + brightness = brightness, + compressionFactor = compressionFactor, + contrast = contrast, + gamma = gamma, + highlight = highlight, + noiseRemoval = noiseRemoval, + shadow = shadow, + sharpen = sharpen, + threshold = threshold, + contextID = contextID, + blankPageDetection = blankPageDetection, + feedDirection = feedDirection, + blankPageDetectionAndRemoval = blankPageDetectionAndRemoval + ) + } +} diff --git a/app/src/main/java/io/github/chrisimx/scanbridge/data/model/ScanRegionState.kt b/app/src/main/java/io/github/chrisimx/scanbridge/data/model/ScanRegionState.kt deleted file mode 100644 index 31321da..0000000 --- a/app/src/main/java/io/github/chrisimx/scanbridge/data/model/ScanRegionState.kt +++ /dev/null @@ -1,99 +0,0 @@ -package io.github.chrisimx.scanbridge.data.model - -import androidx.compose.runtime.MutableState -import androidx.compose.runtime.State -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.setValue -import io.github.chrisimx.esclkt.InputSourceCaps -import io.github.chrisimx.esclkt.LengthUnit -import io.github.chrisimx.esclkt.ScanRegion -import io.github.chrisimx.esclkt.millimeters -import io.github.chrisimx.scanbridge.util.toDoubleLocalized -import kotlinx.serialization.Serializable - -@Serializable -data class ImmutableScanRegionState( - // These are to be given in millimeters! - private val heightState: State, - private val widthState: State, - private val xOffsetState: State, - private val yOffsetState: State -) { - val height by heightState - val width by widthState - val xOffset by xOffsetState - val yOffset by yOffsetState - - fun toESCLScanRegion(selectedInputSourceCaps: InputSourceCaps): ScanRegion { - val height: LengthUnit = when (height) { - "max" -> selectedInputSourceCaps.maxHeight - else -> height.toDoubleLocalized().millimeters() - } - val width: LengthUnit = when (width) { - "max" -> selectedInputSourceCaps.maxWidth - else -> width.toDoubleLocalized().millimeters() - } - - return ScanRegion( - height.toThreeHundredthsOfInch(), - width.toThreeHundredthsOfInch(), - xOffset.toDoubleLocalized().millimeters().toThreeHundredthsOfInch(), - yOffset.toDoubleLocalized().millimeters().toThreeHundredthsOfInch() - ) - } -} - -@Serializable -data class StatelessImmutableScanRegion( - // These are to be given in millimeters! - val height: String, - val width: String, - val xOffset: String, - val yOffset: String -) { - - fun toESCLScanRegion(selectedInputSourceCaps: InputSourceCaps): ScanRegion { - val height: LengthUnit = when (height) { - "max" -> selectedInputSourceCaps.maxHeight - else -> height.toDoubleLocalized().millimeters() - } - val width: LengthUnit = when (width) { - "max" -> selectedInputSourceCaps.maxWidth - else -> width.toDoubleLocalized().millimeters() - } - - return ScanRegion( - height.toThreeHundredthsOfInch(), - width.toThreeHundredthsOfInch(), - xOffset.toDoubleLocalized().millimeters().toThreeHundredthsOfInch(), - yOffset.toDoubleLocalized().millimeters().toThreeHundredthsOfInch() - ) - } - - fun toMutable(): MutableScanRegionState = MutableScanRegionState( - mutableStateOf(height), - mutableStateOf(width), - mutableStateOf(xOffset), - mutableStateOf(yOffset) - ) -} - -@Serializable -data class MutableScanRegionState( - // These are to be given in millimeters! - private val heightState: MutableState, - private val widthState: MutableState, - private val xOffsetState: MutableState = mutableStateOf("0"), - private val yOffsetState: MutableState = mutableStateOf("0") -) { - var height by heightState - var width by widthState - var xOffset by xOffsetState - var yOffset by yOffsetState - - fun toImmutable(): ImmutableScanRegionState = ImmutableScanRegionState(heightState, widthState, xOffsetState, yOffsetState) - fun toStateless(): StatelessImmutableScanRegion = - StatelessImmutableScanRegion(heightState.value, widthState.value, xOffsetState.value, yOffsetState.value) - fun toESCLScanRegion(selectedInputSourceCaps: InputSourceCaps): ScanRegion = toImmutable().toESCLScanRegion(selectedInputSourceCaps) -} diff --git a/app/src/main/java/io/github/chrisimx/scanbridge/data/model/Session.kt b/app/src/main/java/io/github/chrisimx/scanbridge/data/model/Session.kt index 174a899..14fc6c6 100644 --- a/app/src/main/java/io/github/chrisimx/scanbridge/data/model/Session.kt +++ b/app/src/main/java/io/github/chrisimx/scanbridge/data/model/Session.kt @@ -1,6 +1,9 @@ package io.github.chrisimx.scanbridge.data.model import io.github.chrisimx.esclkt.ScanSettings +import io.github.chrisimx.esclkt.ScannerCapabilities +import io.github.chrisimx.esclkt.getInputSourceCaps +import io.github.chrisimx.esclkt.getInputSourceOptions import io.github.chrisimx.scanbridge.data.ui.ScanMetadata import kotlinx.serialization.Serializable import kotlinx.serialization.json.Json @@ -10,17 +13,17 @@ import timber.log.Timber data class Session( val sessionID: String, val scannedPages: List, - val scanSettings: StatelessImmutableESCLScanSettingsState?, + val scanSettings: ScanSettings?, val tmpFiles: List ) { companion object { - fun fromString(sessionFileString: String, json: Json): Result = try { + fun fromString(sessionFileString: String, json: Json, caps: ScannerCapabilities?): Result = try { Result.success(json.decodeFromString(sessionFileString)) } catch (_: Exception) { try { Timber.e("Could not decode Session at $sessionFileString. Trying with old format") val oldSessionVersion = json.decodeFromString(sessionFileString) - Result.success(oldSessionVersion.migrateToNew()) + Result.success(oldSessionVersion.migrateToNew(caps)) } catch (e: Exception) { Result.failure(e) } @@ -35,8 +38,19 @@ data class SessionOld( val scanSettings: StatelessImmutableESCLScanSettingsState?, val tmpFiles: List ) { - fun migrateToNew(): Session { + fun migrateToNew(caps: ScannerCapabilities?): Session { val scannedPages = this.scannedPages.map { ScanMetadata(it.first, it.second) } - return Session(this.sessionID, scannedPages, this.scanSettings, this.tmpFiles) + + val scanSettings = if (caps != null) { + val selectedInput = this.scanSettings?.inputSource ?: caps.getInputSourceOptions().first() + val duplex = this.scanSettings?.duplex ?: false + val inputSourceCaps = caps.getInputSourceCaps(selectedInput, duplex) + + this.scanSettings?.toESCLKtScanSettings(inputSourceCaps) + } else { + this.scanSettings?.toESCLKtScanSettings(null) + } + + return Session(this.sessionID, scannedPages, scanSettings, this.tmpFiles) } } diff --git a/app/src/main/java/io/github/chrisimx/scanbridge/data/ui/ScanSettingsComposableData.kt b/app/src/main/java/io/github/chrisimx/scanbridge/data/ui/ScanSettingsComposableData.kt index 15a7c41..95599d7 100644 --- a/app/src/main/java/io/github/chrisimx/scanbridge/data/ui/ScanSettingsComposableData.kt +++ b/app/src/main/java/io/github/chrisimx/scanbridge/data/ui/ScanSettingsComposableData.kt @@ -19,122 +19,27 @@ package io.github.chrisimx.scanbridge.data.ui -import androidx.compose.runtime.State -import androidx.compose.runtime.derivedStateOf -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.setValue -import io.github.chrisimx.esclkt.DiscreteResolution -import io.github.chrisimx.esclkt.InputSource -import io.github.chrisimx.esclkt.InputSourceCaps -import io.github.chrisimx.esclkt.ScanIntentEnumOrRaw +import io.github.chrisimx.esclkt.ScanSettings import io.github.chrisimx.esclkt.ScannerCapabilities -import io.github.chrisimx.esclkt.SupportedResolutions -import io.github.chrisimx.scanbridge.data.model.ImmutableESCLScanSettingsState -import io.github.chrisimx.scanbridge.data.model.MutableESCLScanSettingsState import io.github.chrisimx.scanbridge.data.model.PaperFormat -import io.github.chrisimx.scanbridge.data.model.StatelessImmutableESCLScanSettingsState import io.github.chrisimx.scanbridge.data.model.loadDefaultFormats -import io.github.chrisimx.scanbridge.util.getInputSourceOptions import kotlinx.serialization.Serializable -@Serializable -data class ScanSettingsComposableData(val scanSettingsState: MutableESCLScanSettingsState, val capabilities: ScannerCapabilities) { - val inputSourceOptions: List = - capabilities.getInputSourceOptions() - val paperFormats: List = loadDefaultFormats() - val duplexAdfSupported = capabilities.adf?.duplexCaps != null - - val customMenuEnabledState = mutableStateOf(false) - var customMenuEnabled by customMenuEnabledState - - val widthTextFieldState = mutableStateOf("") - var widthTextFieldString by widthTextFieldState - val heightTextFieldState = mutableStateOf("") - var heightTextFieldString by heightTextFieldState - - private val selectedInputSourceCapabilitiesState = derivedStateOf { - when (scanSettingsState.inputSource) { - InputSource.Platen -> capabilities.platen!!.inputSourceCaps - InputSource.Feeder -> if (scanSettingsState.duplex == true) capabilities.adf!!.duplexCaps!! else capabilities.adf!!.simplexCaps - InputSource.Camera -> throw UnsupportedOperationException("Camera is not supported yet!") - null -> capabilities.platen!!.inputSourceCaps // assumes default is Platen - } - } - val selectedInputSourceCapabilities by selectedInputSourceCapabilitiesState - - private val intentOptionsState = derivedStateOf { - selectedInputSourceCapabilities.supportedIntents - } - val intentOptions by intentOptionsState - - val supportedScanResolutionsState = derivedStateOf { - selectedInputSourceCapabilities.settingProfiles[0].supportedResolutions - } - val supportedScanResolutions by supportedScanResolutionsState - - fun toImmutable(): ImmutableScanSettingsComposableData = ImmutableScanSettingsComposableData( - scanSettingsState.toImmutable(), - capabilities, - inputSourceOptions, - paperFormats, - duplexAdfSupported, - widthTextFieldState, - heightTextFieldState, - customMenuEnabledState, - selectedInputSourceCapabilitiesState, - intentOptionsState, - supportedScanResolutionsState - ) - - fun toStateless(): StatelessImmutableScanSettingsComposableData = StatelessImmutableScanSettingsComposableData( - scanSettingsState.toStateless(), - capabilities, - inputSourceOptions, - paperFormats, - duplexAdfSupported, - widthTextFieldState.value, - heightTextFieldState.value, - customMenuEnabledState.value, - selectedInputSourceCapabilitiesState.value, - intentOptionsState.value, - supportedScanResolutionsState.value.discreteResolutions - ) -} @Serializable -data class ImmutableScanSettingsComposableData( - val scanSettingsState: ImmutableESCLScanSettingsState, - val capabilities: ScannerCapabilities, - val inputSourceOptions: List, - val paperFormats: List, - val duplexAdfSupported: Boolean, - private val widthTextFieldState: State, - private val heightTextFieldState: State, - private val customMenuEnabledState: State, - private val selectedInputSourceCapabilitiesState: State, - private val intentOptionsState: State>, - private val supportedScanResolutionsState: State -) { - val customMenuEnabled by customMenuEnabledState - val widthTextFieldString by widthTextFieldState - val heightTextFieldString by heightTextFieldState - val selectedInputSourceCapabilities by selectedInputSourceCapabilitiesState - val intentOptions by intentOptionsState - val supportedScanResolutions by supportedScanResolutionsState +sealed class NumberValidationError { + data class OutOfRange(val min: Double, val max: Double) : NumberValidationError() + data object NotANumber : NumberValidationError() } @Serializable -data class StatelessImmutableScanSettingsComposableData( - val scanSettings: StatelessImmutableESCLScanSettingsState, +data class ScanSettingsComposableData( + val scanSettings: ScanSettings, val capabilities: ScannerCapabilities, - val inputSourceOptions: List, - val paperFormats: List, - val duplexAdfSupported: Boolean, - val widthTextField: String, - val heightTextField: String, + val paperFormats: List = loadDefaultFormats(), val customMenuEnabled: Boolean, - val selectedInputSourceCapabilities: InputSourceCaps, - val intentOptions: List, - val supportedScanResolutions: List + val widthString: String, + val widthError: NumberValidationError?, + val heightString: String, + val heightError: NumberValidationError? ) diff --git a/app/src/main/java/io/github/chrisimx/scanbridge/data/ui/ScanSettingsComposableViewModel.kt b/app/src/main/java/io/github/chrisimx/scanbridge/data/ui/ScanSettingsComposableViewModel.kt index 285afed..4aa34a6 100644 --- a/app/src/main/java/io/github/chrisimx/scanbridge/data/ui/ScanSettingsComposableViewModel.kt +++ b/app/src/main/java/io/github/chrisimx/scanbridge/data/ui/ScanSettingsComposableViewModel.kt @@ -19,106 +19,196 @@ package io.github.chrisimx.scanbridge.data.ui +import android.content.res.Resources +import android.icu.number.NumberFormatter import androidx.compose.runtime.mutableStateOf import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope import io.github.chrisimx.esclkt.DiscreteResolution import io.github.chrisimx.esclkt.InputSource +import io.github.chrisimx.esclkt.InputSourceCaps +import io.github.chrisimx.esclkt.LengthUnit import io.github.chrisimx.esclkt.ScanIntentEnumOrRaw -import io.github.chrisimx.scanbridge.data.model.MutableScanRegionState +import io.github.chrisimx.esclkt.ScanRegionLength +import io.github.chrisimx.esclkt.ScanSettings +import io.github.chrisimx.esclkt.getInputSourceCaps +import io.github.chrisimx.esclkt.getInputSourceOptions +import io.github.chrisimx.esclkt.millimeters +import io.github.chrisimx.esclkt.scanRegion +import io.github.chrisimx.esclkt.threeHundredthsOfInch +import io.github.chrisimx.scanbridge.util.derived +import io.github.chrisimx.scanbridge.util.getMaxResolution +import io.github.chrisimx.scanbridge.util.toDoubleLocalized +import java.text.ParseException +import java.util.Locale +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.flow.update class ScanSettingsComposableViewModel( - private val _scanSettingsComposableData: ScanSettingsComposableData, - private val onSettingsChanged: (() -> Unit)? = null + private val initialScanSettingsData: ScanSettingsComposableData, + private val onSettingsChanged: (() -> Unit)? = null, ) : ViewModel() { - val scanSettingsComposableData: ImmutableScanSettingsComposableData - get() = _scanSettingsComposableData.toImmutable() + private val _uiState = MutableStateFlow(initialScanSettingsData) + val uiState: StateFlow = _uiState.asStateFlow() - fun getMutableScanSettingsComposableData(): ScanSettingsComposableData = _scanSettingsComposableData + val inputSourceOptions: StateFlow> = _uiState.derived(viewModelScope) { + it.capabilities.getInputSourceOptions() + } + + val duplexAdfSupported: StateFlow = _uiState.derived(viewModelScope) { + it.capabilities.adf?.duplexCaps != null + } + + private val selectedInputSourceCaps: StateFlow = _uiState.derived(viewModelScope) { + it.capabilities.getInputSourceCaps(it.scanSettings.inputSource, it.scanSettings.duplex ?: false) + } + + private val intentOptions = selectedInputSourceCaps.derived(viewModelScope) { + it.supportedIntents + } + + val supportedScanResolutions = selectedInputSourceCaps.derived(viewModelScope) { + it.settingProfiles[0].supportedResolutions + } + + private inline fun ScanSettingsComposableData.updateScanSettings(update: ScanSettings.() -> ScanSettings) = + copy(scanSettings = scanSettings.update()) + + private inline fun MutableStateFlow.updateScanSettings(updateLambda: ScanSettings.() -> ScanSettings) = + this.update { + it.updateScanSettings(updateLambda) + } + + init { + _uiState + .map { it.scanSettings } + .distinctUntilChanged() + .onEach { onSettingsChanged?.invoke() } + .launchIn(viewModelScope) + + } fun setDuplex(duplex: Boolean) { - _scanSettingsComposableData.scanSettingsState.duplex = duplex - onSettingsChanged?.invoke() + _uiState.updateScanSettings { + copy(duplex = duplex) + } } fun setInputSourceOptions(inputSource: InputSource) { - val scanSettingsState = _scanSettingsComposableData.scanSettingsState - scanSettingsState.inputSource = inputSource - revalidateSettings() - onSettingsChanged?.invoke() - } - - fun revalidateSettings() { - val scanSettingsState = _scanSettingsComposableData.scanSettingsState - val isResolutionSupported = _scanSettingsComposableData.supportedScanResolutions.discreteResolutions.contains( - DiscreteResolution(scanSettingsState.xResolution, scanSettingsState.yResolution) - ) - if (!isResolutionSupported) { - val highestScanResolution = _scanSettingsComposableData.supportedScanResolutions.discreteResolutions.maxBy { - it.xResolution * - it.yResolution + _uiState.update { + val currentScanSettings = it.scanSettings + val inputSourceCaps = it.capabilities.getInputSourceCaps(inputSource) + + val supportedResolutions = inputSourceCaps.settingProfiles[0].supportedResolutions.discreteResolutions + + val xRes = currentScanSettings.xResolution + val yRes = currentScanSettings.yResolution + + val validResolutionSetting = xRes != null && yRes != null + && !supportedResolutions.contains(DiscreteResolution(xRes, yRes)) + + val replacementResolution = if (validResolutionSetting) { + val highestScanResolution = it.capabilities.getMaxResolution(inputSource) + + Pair(highestScanResolution.xResolution, highestScanResolution.yResolution) + } else { + Pair(xRes, yRes) + } + + val intentSupported = currentScanSettings.intent?.let { inputSourceCaps.supportedIntents.contains(it) } ?: true + + val replacementIntent = if (intentSupported) { + currentScanSettings.intent + } else { + null } - setResolution(highestScanResolution.xResolution, highestScanResolution.yResolution) - } - val intentSupported = scanSettingsState.intent?.let { _scanSettingsComposableData.intentOptions.contains(it) } - if (intentSupported == false) { - setIntent(null) + it.copy(scanSettings = it.scanSettings.copy( + xResolution = replacementResolution.first, + yResolution = replacementResolution.second, + intent = replacementIntent + )) } } fun setResolution(xResolution: UInt, yResolution: UInt) { - _scanSettingsComposableData.scanSettingsState.xResolution = xResolution - _scanSettingsComposableData.scanSettingsState.yResolution = yResolution - onSettingsChanged?.invoke() + _uiState.updateScanSettings { + copy( + xResolution = xResolution, + yResolution = yResolution + ) + } } fun setIntent(intent: ScanIntentEnumOrRaw?) { - _scanSettingsComposableData.scanSettingsState.intent = intent - onSettingsChanged?.invoke() + _uiState.updateScanSettings { + copy(intent = intent) + } } fun setCustomMenuEnabled(enabled: Boolean) { - _scanSettingsComposableData.customMenuEnabled = enabled + _uiState.update { + it.copy(customMenuEnabled = enabled) + } } - fun setWidthTextFieldContent(width: String) { - _scanSettingsComposableData.widthTextFieldString = width + fun setCustomWidthTextFieldContent(width: String) { + check(_uiState.value.customMenuEnabled) + _uiState.update { + it.copy(widthString = width) + } } - fun setHeightTextFieldContent(width: String) { - _scanSettingsComposableData.heightTextFieldString = width + fun getCurrentUserLocale(): Locale { + return Resources.getSystem().configuration.locales[0] } - fun setRegionDimension(width: String, height: String) { - if (_scanSettingsComposableData.scanSettingsState.scanRegions == null) { - _scanSettingsComposableData.scanSettingsState.scanRegions = MutableScanRegionState( - widthState = mutableStateOf(width), - heightState = mutableStateOf(height), - xOffsetState = mutableStateOf("0"), - yOffsetState = mutableStateOf("0") - ) - onSettingsChanged?.invoke() - return // We don't want to set the width and height twice + + fun setCustomHeightTextFieldContent(height: String) { + check(_uiState.value.customMenuEnabled) + + val parsedHeight = runCatching { + height.toDoubleLocalized() + }.getOrNull() + + _uiState.update { + if (parsedHeight == null) { + return@update it.copy( + heightString = height, + heightError = NumberValidationError.NotANumber + ) + } + + val maxHeight = selectedInputSourceCaps.value.maxHeight.toMillimeters().value + val minHeight = selectedInputSourceCaps.value.minHeight.toMillimeters().value + + val scanSettingsWithNewHeight = it.scanSettings.copy(scanRegions = ) + + it.copy(heightString = height, scanSettings = parsedHeight.coerceIn(minHeight, maxHeight)) } - _scanSettingsComposableData.scanSettingsState.scanRegions!!.width = width.toString() - _scanSettingsComposableData.scanSettingsState.scanRegions!!.height = height.toString() - onSettingsChanged?.invoke() - } - - fun setOffset(xOffset: String, yOffset: String) { - if (_scanSettingsComposableData.scanSettingsState.scanRegions == null) { - _scanSettingsComposableData.scanSettingsState.scanRegions = MutableScanRegionState( - widthState = mutableStateOf("0"), - heightState = mutableStateOf("0"), - xOffsetState = mutableStateOf(xOffset), - yOffsetState = mutableStateOf(yOffset) + } + + fun usesImperial(locale: Locale = Locale.getDefault()): Boolean { + return locale.country in setOf("US", "LR", "MM") + } + + fun setRegionDimension(newWidth: ScanRegionLength, newHeight: ScanRegionLength) { + _uiState.updateScanSettings { + copy( + scanRegions = scanRegion { + width(newWidth) + height(newHeight) + xOffset = 0.millimeters() + yOffset = 0.millimeters() + } ) - onSettingsChanged?.invoke() - return // We don't want to set the width and height twice } - _scanSettingsComposableData.scanSettingsState.scanRegions!!.xOffset = xOffset.toString() - _scanSettingsComposableData.scanSettingsState.scanRegions!!.yOffset = yOffset.toString() - onSettingsChanged?.invoke() } } diff --git a/app/src/main/java/io/github/chrisimx/scanbridge/data/ui/ScanningScreenViewModel.kt b/app/src/main/java/io/github/chrisimx/scanbridge/data/ui/ScanningScreenViewModel.kt index 0b25f67..01bee43 100644 --- a/app/src/main/java/io/github/chrisimx/scanbridge/data/ui/ScanningScreenViewModel.kt +++ b/app/src/main/java/io/github/chrisimx/scanbridge/data/ui/ScanningScreenViewModel.kt @@ -225,7 +225,7 @@ class ScanningScreenViewModel( fun setScannerCapabilities(caps: ScannerCapabilities) { _scanningScreenData.capabilities.value = caps - val storedSessionResult = loadSessionFile() + val storedSessionResult = loadSessionFile(caps) storedSessionResult.onFailure { setError( @@ -351,7 +351,7 @@ class ScanningScreenViewModel( } @OptIn(ExperimentalSerializationApi::class) - fun loadSessionFile(): Result = SessionsStore.loadSession(application, scanningScreenData.sessionID) + fun loadSessionFile(caps: ScannerCapabilities): Result = SessionsStore.loadSession(application, scanningScreenData.sessionID, caps) fun swapTwoPages(index1: Int, index2: Int) { if (index1 < 0 || diff --git a/app/src/main/java/io/github/chrisimx/scanbridge/stores/SessionsStore.kt b/app/src/main/java/io/github/chrisimx/scanbridge/stores/SessionsStore.kt index 09ba969..4c08eff 100644 --- a/app/src/main/java/io/github/chrisimx/scanbridge/stores/SessionsStore.kt +++ b/app/src/main/java/io/github/chrisimx/scanbridge/stores/SessionsStore.kt @@ -5,6 +5,7 @@ import io.github.chrisimx.esclkt.Inches import io.github.chrisimx.esclkt.LengthUnit import io.github.chrisimx.esclkt.Millimeters import io.github.chrisimx.esclkt.Points +import io.github.chrisimx.esclkt.ScannerCapabilities import io.github.chrisimx.esclkt.ThreeHundredthsOfInch import io.github.chrisimx.scanbridge.data.model.Session import io.github.chrisimx.scanbridge.data.model.Session.Companion.fromString @@ -33,7 +34,7 @@ object SessionsStore { prettyPrint = false } - fun loadSession(application: Context, sessionID: String): Result { + fun loadSession(application: Context, sessionID: String, caps: ScannerCapabilities?): Result { Timber.d("Loading session $sessionID") val path = application.applicationInfo.dataDir + "/files/" + sessionID + ".session" @@ -46,7 +47,7 @@ object SessionsStore { val sessionFileString = file.readText() - return fromString(sessionFileString, json) + return fromString(sessionFileString, json, caps) } @OptIn(ExperimentalSerializationApi::class) diff --git a/app/src/main/java/io/github/chrisimx/scanbridge/util/ESCLKtExtensions.kt b/app/src/main/java/io/github/chrisimx/scanbridge/util/ESCLKtExtensions.kt index aee1ed9..0798e99 100644 --- a/app/src/main/java/io/github/chrisimx/scanbridge/util/ESCLKtExtensions.kt +++ b/app/src/main/java/io/github/chrisimx/scanbridge/util/ESCLKtExtensions.kt @@ -20,18 +20,20 @@ package io.github.chrisimx.scanbridge.util import android.content.Context +import android.icu.number.NumberFormatter import android.icu.text.DecimalFormat import androidx.compose.runtime.mutableStateOf import io.github.chrisimx.esclkt.ColorModeEnumOrRaw import io.github.chrisimx.esclkt.DiscreteResolution import io.github.chrisimx.esclkt.EnumOrRaw import io.github.chrisimx.esclkt.InputSource -import io.github.chrisimx.esclkt.InputSourceCaps import io.github.chrisimx.esclkt.JobState +import io.github.chrisimx.esclkt.ScanSettings import io.github.chrisimx.esclkt.ScannerCapabilities +import io.github.chrisimx.esclkt.getInputSourceCaps +import io.github.chrisimx.esclkt.getInputSourceOptions +import io.github.chrisimx.esclkt.scanRegion import io.github.chrisimx.scanbridge.R -import io.github.chrisimx.scanbridge.data.model.MutableESCLScanSettingsState -import io.github.chrisimx.scanbridge.data.model.MutableScanRegionState fun JobState?.toJobStateString(context: Context): String = when (this) { JobState.Canceled -> context.getString(R.string.job_canceled) @@ -46,22 +48,6 @@ fun String.toDoubleLocalized(): Double = DecimalFormat.getInstance().parse(this) fun Double.toStringLocalized(): String = DecimalFormat.getInstance().format(this) -fun ScannerCapabilities.getInputSourceOptions(): List { - val tmpInputSourceOptions = mutableListOf() - if (this.platen != null) { - tmpInputSourceOptions.add(InputSource.Platen) - } - if (this.adf != null) { - tmpInputSourceOptions.add(InputSource.Feeder) - } - return tmpInputSourceOptions -} - -fun ScannerCapabilities.getInputSourceCaps(inputSource: InputSource, duplex: Boolean = false): InputSourceCaps = when (inputSource) { - InputSource.Platen -> this.platen!!.inputSourceCaps - InputSource.Feeder -> if (duplex) this.adf!!.duplexCaps!! else this.adf!!.simplexCaps - InputSource.Camera -> TODO() -} fun InputSource.toReadableString(context: Context): String = when (this) { InputSource.Platen -> context.getString(R.string.platen) @@ -89,25 +75,27 @@ fun ScannerCapabilities.getBestColorMode(inputSource: InputSource): ColorModeEnu return chosenColorMode } -fun ScannerCapabilities.calculateDefaultESCLScanSettingsState(): MutableESCLScanSettingsState { +fun ScannerCapabilities.calculateDefaultESCLScanSettingsState(): ScanSettings { val inputSource = this.getInputSourceOptions().firstOrNull() ?: InputSource.Platen val maxResolution = getMaxResolution(inputSource) - val maxScanRegion = MutableScanRegionState( - heightState = mutableStateOf("max"), - widthState = mutableStateOf("max") - ) + val inputSourceCaps = this.getInputSourceCaps(inputSource, false) + + val maxScanRegion = scanRegion(inputSourceCaps) { + maxHeight() + maxWidth() + } val bestColorMode = getBestColorMode(inputSource) - return MutableESCLScanSettingsState( - versionState = mutableStateOf(this.interfaceVersion), - inputSourceState = mutableStateOf(inputSource), - scanRegionsState = mutableStateOf(maxScanRegion), - xResolutionState = mutableStateOf(maxResolution.xResolution), - yResolutionState = mutableStateOf(maxResolution.yResolution), - colorModeState = mutableStateOf(bestColorMode), - documentFormatExtState = mutableStateOf("image/jpeg") + return ScanSettings( + version = this.interfaceVersion, + inputSource = inputSource, + scanRegions = maxScanRegion, + xResolution = maxResolution.xResolution, + yResolution = maxResolution.yResolution, + colorMode = bestColorMode, + documentFormatExt = "image/jpeg" ) } diff --git a/app/src/main/java/io/github/chrisimx/scanbridge/util/StateFlowExtensions.kt b/app/src/main/java/io/github/chrisimx/scanbridge/util/StateFlowExtensions.kt new file mode 100644 index 0000000..5cf0736 --- /dev/null +++ b/app/src/main/java/io/github/chrisimx/scanbridge/util/StateFlowExtensions.kt @@ -0,0 +1,17 @@ +package io.github.chrisimx.scanbridge.util + +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.stateIn + +fun StateFlow.derived( + scope: CoroutineScope, + mapper: (T) -> R +): StateFlow = map(mapper) + .stateIn( + scope = scope, + started = SharingStarted.Lazily, + initialValue = mapper(value) + ) From 7d59884d670955186968223f4341106694862d27 Mon Sep 17 00:00:00 2001 From: Christian Nagel Date: Fri, 20 Feb 2026 13:14:23 +0100 Subject: [PATCH 02/16] Remove getCurrentUserLocale from ScanSettingsComposableViewModel --- .../data/ui/ScanSettingsComposableViewModel.kt | 13 +------------ 1 file changed, 1 insertion(+), 12 deletions(-) diff --git a/app/src/main/java/io/github/chrisimx/scanbridge/data/ui/ScanSettingsComposableViewModel.kt b/app/src/main/java/io/github/chrisimx/scanbridge/data/ui/ScanSettingsComposableViewModel.kt index 4aa34a6..6eefc34 100644 --- a/app/src/main/java/io/github/chrisimx/scanbridge/data/ui/ScanSettingsComposableViewModel.kt +++ b/app/src/main/java/io/github/chrisimx/scanbridge/data/ui/ScanSettingsComposableViewModel.kt @@ -19,15 +19,11 @@ package io.github.chrisimx.scanbridge.data.ui -import android.content.res.Resources -import android.icu.number.NumberFormatter -import androidx.compose.runtime.mutableStateOf import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import io.github.chrisimx.esclkt.DiscreteResolution import io.github.chrisimx.esclkt.InputSource import io.github.chrisimx.esclkt.InputSourceCaps -import io.github.chrisimx.esclkt.LengthUnit import io.github.chrisimx.esclkt.ScanIntentEnumOrRaw import io.github.chrisimx.esclkt.ScanRegionLength import io.github.chrisimx.esclkt.ScanSettings @@ -35,12 +31,10 @@ import io.github.chrisimx.esclkt.getInputSourceCaps import io.github.chrisimx.esclkt.getInputSourceOptions import io.github.chrisimx.esclkt.millimeters import io.github.chrisimx.esclkt.scanRegion -import io.github.chrisimx.esclkt.threeHundredthsOfInch import io.github.chrisimx.scanbridge.util.derived import io.github.chrisimx.scanbridge.util.getMaxResolution import io.github.chrisimx.scanbridge.util.toDoubleLocalized -import java.text.ParseException -import java.util.Locale +import java.util.* import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow @@ -166,11 +160,6 @@ class ScanSettingsComposableViewModel( } } - fun getCurrentUserLocale(): Locale { - return Resources.getSystem().configuration.locales[0] - } - - fun setCustomHeightTextFieldContent(height: String) { check(_uiState.value.customMenuEnabled) From 491d5b7059c0944320debe3f85071ae1a9285eef Mon Sep 17 00:00:00 2001 From: Christian Nagel Date: Fri, 20 Feb 2026 19:23:31 +0100 Subject: [PATCH 03/16] Modernize ScanSettingsComposableViewModel --- app/build.gradle.kts | 7 + .../chrisimx/scanbridge/MainActivity.kt | 7 +- .../scanbridge/ScanBridgeApplication.kt | 21 ++ .../chrisimx/scanbridge/ScanSettings.kt | 164 +++++------ .../chrisimx/scanbridge/ScanningScreen.kt | 1 - .../data/ui/ScanSettingsComposableData.kt | 16 +- .../ui/ScanSettingsComposableViewModel.kt | 275 +++++++++++++++--- .../data/ui/ScanningScreenViewModel.kt | 133 ++++++--- .../AndroidLocaleProvider.kt} | 10 +- .../scanbridge/services/LocaleProvider.kt | 9 + .../stores/DefaultScanSettingsStore.kt | 3 +- .../uicomponents/ValidatedTextField.kt | 53 +--- app/src/main/res/values-de/strings.xml | 4 +- app/src/main/res/values-it/strings.xml | 4 +- app/src/main/res/values/strings.xml | 6 +- gradle/libs.versions.toml | 24 +- 16 files changed, 500 insertions(+), 237 deletions(-) rename app/src/main/java/io/github/chrisimx/scanbridge/{util/LocaleProvider.kt => services/AndroidLocaleProvider.kt} (63%) create mode 100644 app/src/main/java/io/github/chrisimx/scanbridge/services/LocaleProvider.kt diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 68b9ccb..f1ab832 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -15,6 +15,7 @@ plugins { alias(libs.plugins.kotlin.android) alias(libs.plugins.kotlin.compose) alias(libs.plugins.kotlin.serialization) + alias(libs.plugins.koin) } fun getGitCommitHash(): String { @@ -118,6 +119,12 @@ kotlin { } dependencies { + implementation(libs.koin.core) + implementation(libs.koin.android) + implementation(libs.koin.compose) + implementation(libs.koin.annotations) + implementation(libs.koin.compose.viewmodel) + implementation(libs.koin.androix.navigation) implementation(libs.esclkt) implementation(libs.zoomable) implementation(libs.kotlin.reflect) diff --git a/app/src/main/java/io/github/chrisimx/scanbridge/MainActivity.kt b/app/src/main/java/io/github/chrisimx/scanbridge/MainActivity.kt index 2a519f0..1448e8f 100644 --- a/app/src/main/java/io/github/chrisimx/scanbridge/MainActivity.kt +++ b/app/src/main/java/io/github/chrisimx/scanbridge/MainActivity.kt @@ -28,13 +28,14 @@ import androidx.activity.enableEdgeToEdge import androidx.activity.result.ActivityResultLauncher import androidx.activity.result.contract.ActivityResultContracts import io.github.chrisimx.scanbridge.logs.FileLogger -import io.github.chrisimx.scanbridge.util.LocaleProvider +import io.github.chrisimx.scanbridge.services.AndroidLocaleProvider import java.io.BufferedWriter import java.io.File import java.io.FileWriter import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch +import org.koin.android.ext.android.inject import timber.log.Timber class MainActivity : ComponentActivity() { @@ -42,12 +43,14 @@ class MainActivity : ComponentActivity() { var tree: Timber.Tree? = null var saveDebugFileLauncher: ActivityResultLauncher? = null + private val localeProvider: AndroidLocaleProvider by inject() + override fun onCreate(savedInstanceState: Bundle?) { Thread.setDefaultUncaughtExceptionHandler(CrashHandler(this)) enableEdgeToEdge() super.onCreate(savedInstanceState) - LocaleProvider.update() + localeProvider.update() val sharedPreferences = this.getSharedPreferences("scanbridge", MODE_PRIVATE) diff --git a/app/src/main/java/io/github/chrisimx/scanbridge/ScanBridgeApplication.kt b/app/src/main/java/io/github/chrisimx/scanbridge/ScanBridgeApplication.kt index a52033a..9b11be1 100644 --- a/app/src/main/java/io/github/chrisimx/scanbridge/ScanBridgeApplication.kt +++ b/app/src/main/java/io/github/chrisimx/scanbridge/ScanBridgeApplication.kt @@ -1,11 +1,32 @@ package io.github.chrisimx.scanbridge import android.app.Application +import io.github.chrisimx.scanbridge.data.ui.ScanSettingsComposableViewModel +import io.github.chrisimx.scanbridge.data.ui.ScanningScreenViewModel +import io.github.chrisimx.scanbridge.services.AndroidLocaleProvider +import io.github.chrisimx.scanbridge.services.LocaleProvider +import org.koin.android.ext.koin.androidContext +import org.koin.core.context.startKoin +import org.koin.dsl.bind +import org.koin.dsl.module +import org.koin.plugin.module.dsl.single +import org.koin.plugin.module.dsl.viewModel import timber.log.Timber +val appModule = module { + single() bind LocaleProvider::class + viewModel() + viewModel() +} + class ScanBridgeApplication : Application() { override fun onCreate() { super.onCreate() + startKoin { + androidContext(this@ScanBridgeApplication) + modules(appModule) + } + Timber.plant(Timber.DebugTree()) } } diff --git a/app/src/main/java/io/github/chrisimx/scanbridge/ScanSettings.kt b/app/src/main/java/io/github/chrisimx/scanbridge/ScanSettings.kt index 293ec53..137c243 100644 --- a/app/src/main/java/io/github/chrisimx/scanbridge/ScanSettings.kt +++ b/app/src/main/java/io/github/chrisimx/scanbridge/ScanSettings.kt @@ -19,10 +19,6 @@ package io.github.chrisimx.scanbridge -import android.content.ClipData -import android.content.ClipboardManager -import android.content.Context -import android.content.Context.CLIPBOARD_SERVICE import androidx.compose.animation.AnimatedVisibility import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.layout.Arrangement @@ -57,13 +53,16 @@ import androidx.compose.ui.platform.testTag import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp -import androidx.lifecycle.viewmodel.compose.viewModel -import io.github.chrisimx.esclkt.InputSource -import io.github.chrisimx.scanbridge.data.ui.ImmutableScanSettingsComposableData +import io.github.chrisimx.esclkt.DiscreteResolution +import io.github.chrisimx.esclkt.SupportedResolutions +import io.github.chrisimx.esclkt.equalsLength +import io.github.chrisimx.scanbridge.data.ui.NumberValidationResult import io.github.chrisimx.scanbridge.data.ui.ScanSettingsComposableViewModel +import io.github.chrisimx.scanbridge.data.ui.ScanSettingsLengthUnit import io.github.chrisimx.scanbridge.uicomponents.SizeBasedConditionalView import io.github.chrisimx.scanbridge.uicomponents.ValidatedDimensionsTextEdit import io.github.chrisimx.scanbridge.util.toReadableString +import org.koin.androidx.compose.koinViewModel import timber.log.Timber @OptIn( @@ -72,10 +71,33 @@ import timber.log.Timber ) private val TAG = "ScanSettings" + @OptIn(ExperimentalLayoutApi::class, ExperimentalMaterial3ExpressiveApi::class) @Composable -fun ScanSettingsUI(modifier: Modifier, context: Context, scanSettingsViewModel: ScanSettingsComposableViewModel = viewModel()) { - val vmData = scanSettingsViewModel.uiState.collectAsState() +fun ScanSettingsUI(modifier: Modifier, + scanSettingsViewModel: ScanSettingsComposableViewModel = koinViewModel() +) { + val context = LocalContext.current + val vmData by scanSettingsViewModel.uiState.collectAsState() + + val currentResolution by scanSettingsViewModel.currentResolution.collectAsState() + val currentScanRegion by scanSettingsViewModel.currentScanRegion.collectAsState() + + val duplexCurrentlyAvailable by scanSettingsViewModel.duplexCurrentlyAvailable.collectAsState() + + val inputSourceOptions by scanSettingsViewModel.inputSourceOptions.collectAsState() + val supportedResolutions by scanSettingsViewModel.supportedScanResolutions.collectAsState() + val intentOptions by scanSettingsViewModel.intentOptions.collectAsState() + + val widthValidationResult by scanSettingsViewModel.widthValidationResult.collectAsState(NumberValidationResult.NotANumber) + val heightValidationResult by scanSettingsViewModel.heightValidationResult.collectAsState(NumberValidationResult.NotANumber) + + val userUnitEnum by scanSettingsViewModel.lengthUnit.collectAsState(ScanSettingsLengthUnit.MILLIMETER) + + val userUnitString = when (userUnitEnum) { + ScanSettingsLengthUnit.INCH -> stringResource(R.string.inches) + ScanSettingsLengthUnit.MILLIMETER -> stringResource(R.string.mm) + } val scrollState = rememberScrollState() @@ -94,24 +116,22 @@ fun ScanSettingsUI(modifier: Modifier, context: Context, scanSettingsViewModel: verticalArrangement = Arrangement.spacedBy(10.dp, Alignment.CenterVertically) ) { SingleChoiceSegmentedButtonRow { - scanSettingsUIState.inputSourceOptions.forEachIndexed { index, inputSource -> + inputSourceOptions.forEachIndexed { index, inputSource -> SegmentedButton( shape = SegmentedButtonDefaults.itemShape( index = index, - count = scanSettingsUIState.inputSourceOptions.size + count = inputSourceOptions.size ), - onClick = { scanSettingsViewModel.setInputSourceOptions(inputSource) }, - selected = scanSettingsUIState.scanSettingsState.inputSource == inputSource + onClick = { scanSettingsViewModel.setInputSource(inputSource) }, + selected = vmData.scanSettings.inputSource == inputSource ) { Text(inputSource.toReadableString(context)) } } } - val duplexAvailable = - scanSettingsUIState.duplexAdfSupported && scanSettingsUIState.scanSettingsState.inputSource == InputSource.Feeder ToggleButton( - enabled = duplexAvailable, - checked = scanSettingsUIState.scanSettingsState.duplex == true, + enabled = duplexCurrentlyAvailable, + checked = vmData.scanSettings.duplex == true, onCheckedChange = { scanSettingsViewModel.setDuplex(it) } ) { Text(stringResource(R.string.setting_duplex)) } } @@ -121,10 +141,14 @@ fun ScanSettingsUI(modifier: Modifier, context: Context, scanSettingsViewModel: SizeBasedConditionalView( modifier = Modifier, largeView = { - ResolutionSettingButtonRowVersion(scanSettingsUIState, scanSettingsViewModel) + ResolutionSettingButtonRowVersion(supportedResolutions, currentResolution) { x, y -> + scanSettingsViewModel.setResolution(x, y) + } }, smallView = { - ResolutionSettingCardVersion(scanSettingsUIState, scanSettingsViewModel) + ResolutionSettingCardVersion(supportedResolutions, currentResolution) { x, y -> + scanSettingsViewModel.setResolution(x, y) + } }, onViewChosen = { fitsRowVersion = it } ) @@ -146,14 +170,14 @@ fun ScanSettingsUI(modifier: Modifier, context: Context, scanSettingsViewModel: horizontalArrangement = Arrangement.SpaceEvenly ) { - scanSettingsUIState.intentOptions.forEach { intentData -> + intentOptions.forEach { intentData -> val name = intentData.asString() InputChip( onClick = { scanSettingsViewModel.setIntent(intentData) }, label = { Text(name) }, - selected = scanSettingsUIState.scanSettingsState.intent == intentData + selected = vmData.scanSettings.intent == intentData ) } InputChip( @@ -161,7 +185,7 @@ fun ScanSettingsUI(modifier: Modifier, context: Context, scanSettingsViewModel: scanSettingsViewModel.setIntent(null) }, label = { Text(stringResource(R.string.intent_none)) }, - selected = scanSettingsUIState.scanSettingsState.intent == null + selected = vmData.scanSettings.intent == null ) } } @@ -182,100 +206,69 @@ fun ScanSettingsUI(modifier: Modifier, context: Context, scanSettingsViewModel: Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceEvenly ) { - scanSettingsUIState.paperFormats.forEach { paperFormat -> + vmData.paperFormats.forEach { paperFormat -> InputChip( onClick = { scanSettingsViewModel.setCustomMenuEnabled(false) scanSettingsViewModel.setRegionDimension( - paperFormat.width.toMillimeters().value.toString(), - paperFormat.height.toMillimeters().value.toString() + paperFormat.width, paperFormat.height ) - Timber.tag(TAG).d("New region state: ${scanSettingsUIState.scanSettingsState.scanRegions}") + Timber.tag(TAG).d("New region state: ${vmData.scanSettings.scanRegions}") }, label = { Text(paperFormat.name) }, - selected = - scanSettingsUIState.scanSettingsState.scanRegions?.width == - paperFormat.width.toMillimeters().value.toString() && - scanSettingsUIState.scanSettingsState.scanRegions?.height == - paperFormat.height.toMillimeters().value.toString() && - !scanSettingsUIState.customMenuEnabled + selected = !vmData.customMenuEnabled && !vmData.maximumSize + && currentScanRegion?.width?.equalsLength(paperFormat.width) == true + && currentScanRegion?.height?.equalsLength(paperFormat.height) == true ) } InputChip( onClick = { scanSettingsViewModel.setCustomMenuEnabled(false) - scanSettingsViewModel.setRegionDimension("max", "max") + scanSettingsViewModel.selectMaxRegion() }, label = { Text(stringResource(R.string.maximum_size)) }, selected = - scanSettingsUIState.scanSettingsState.scanRegions?.width == "max" && !scanSettingsUIState.customMenuEnabled + vmData.maximumSize && !vmData.customMenuEnabled ) InputChip( - selected = scanSettingsUIState.customMenuEnabled, + selected = vmData.customMenuEnabled, onClick = { scanSettingsViewModel.setCustomMenuEnabled(true) }, label = { Text(stringResource(R.string.custom)) } ) } - AnimatedVisibility(scanSettingsUIState.customMenuEnabled) { + AnimatedVisibility(vmData.customMenuEnabled) { Row(horizontalArrangement = Arrangement.SpaceEvenly) { ValidatedDimensionsTextEdit( - scanSettingsUIState.widthTextFieldString, + vmData.widthString, context, modifier = Modifier .weight(1f) .padding(end = 10.dp), - stringResource(R.string.width_in_mm), + stringResource(R.string.width_in_unit, userUnitString), { newText: String -> scanSettingsViewModel.setCustomWidthTextFieldContent( newText ) }, - { newWidth: String -> - scanSettingsViewModel.setRegionDimension( - newWidth, - scanSettingsUIState.heightTextFieldString - ) - }, - min = scanSettingsUIState.selectedInputSourceCapabilities.minWidth.toMillimeters().value, - max = scanSettingsUIState.selectedInputSourceCapabilities.maxWidth.toMillimeters().value + widthValidationResult ) ValidatedDimensionsTextEdit( - scanSettingsUIState.heightTextFieldString, + vmData.heightString, context, modifier = Modifier .weight(1f) .padding(start = 10.dp), - stringResource(R.string.height_in_mm), + stringResource(R.string.height_in_unit, userUnitString), { scanSettingsViewModel.setCustomHeightTextFieldContent(it) }, - { - scanSettingsViewModel.setRegionDimension( - scanSettingsUIState.widthTextFieldString, - it - ) - }, - min = scanSettingsUIState.selectedInputSourceCapabilities.minHeight.toMillimeters().value, - max = scanSettingsUIState.selectedInputSourceCapabilities.maxHeight.toMillimeters().value + heightValidationResult ) } } } } - val localContext = LocalContext.current Button( modifier = Modifier.padding(horizontal = 15.dp).testTag("copyesclkt"), - onClick = { - val systemClipboard = - localContext.getSystemService(CLIPBOARD_SERVICE) as ClipboardManager - val scanSettingsString = - scanSettingsUIState.scanSettingsState.toESCLKtScanSettings(scanSettingsUIState.selectedInputSourceCapabilities) - .toString() - systemClipboard.setPrimaryClip( - ClipData.newPlainText( - localContext.getString(R.string.scan_settings), - scanSettingsString - ) - ) - } + onClick = { scanSettingsViewModel.copySettingsToClipboard() } ) { Text( stringResource(R.string.copy_current_scanner_options_in_esclkt_format), @@ -288,27 +281,23 @@ fun ScanSettingsUI(modifier: Modifier, context: Context, scanSettingsViewModel: @Composable private fun ResolutionSettingButtonRowVersion( - scanSettingsUIState: ImmutableScanSettingsComposableData, - scanSettingsViewModel: ScanSettingsComposableViewModel + supportedResolutions: SupportedResolutions, + currentResolution: DiscreteResolution?, + setSelectedResolution: (UInt, UInt) -> Unit ) { Column(horizontalAlignment = Alignment.CenterHorizontally) { Text(stringResource(R.string.resolution_dpi)) SingleChoiceSegmentedButtonRow { - scanSettingsUIState.supportedScanResolutions.discreteResolutions.forEachIndexed { index, discreteResolution -> + supportedResolutions.discreteResolutions.forEachIndexed { index, discreteResolution -> SegmentedButton( shape = SegmentedButtonDefaults.itemShape( index = index, - count = scanSettingsUIState.supportedScanResolutions.discreteResolutions.size + count = supportedResolutions.discreteResolutions.size ), onClick = { - scanSettingsViewModel.setResolution( - discreteResolution.xResolution, - discreteResolution.yResolution - ) + setSelectedResolution(discreteResolution.xResolution, discreteResolution.yResolution) }, - selected = - scanSettingsUIState.scanSettingsState.xResolution == discreteResolution.xResolution && - scanSettingsUIState.scanSettingsState.yResolution == discreteResolution.yResolution + selected = currentResolution == discreteResolution ) { if (discreteResolution.xResolution == discreteResolution.yResolution) { Text("${discreteResolution.xResolution}") @@ -323,8 +312,9 @@ private fun ResolutionSettingButtonRowVersion( @Composable private fun ResolutionSettingCardVersion( - scanSettingsUIState: ImmutableScanSettingsComposableData, - scanSettingsViewModel: ScanSettingsComposableViewModel + supportedResolutions: SupportedResolutions, + currentResolution: DiscreteResolution?, + setSelectedResolution: (UInt, UInt) -> Unit ) { OutlinedCard( modifier = Modifier @@ -343,7 +333,7 @@ private fun ResolutionSettingCardVersion( horizontalArrangement = Arrangement.SpaceEvenly ) { - scanSettingsUIState.supportedScanResolutions.discreteResolutions.forEachIndexed { index, discreteResolution -> + supportedResolutions.discreteResolutions.forEachIndexed { index, discreteResolution -> val text = if (discreteResolution.xResolution == discreteResolution.yResolution) { "${discreteResolution.xResolution}" } else { @@ -351,15 +341,13 @@ private fun ResolutionSettingCardVersion( } InputChip( onClick = { - scanSettingsViewModel.setResolution( + setSelectedResolution( discreteResolution.xResolution, discreteResolution.yResolution ) }, label = { Text(text) }, - selected = - scanSettingsUIState.scanSettingsState.xResolution == discreteResolution.xResolution && - scanSettingsUIState.scanSettingsState.yResolution == discreteResolution.yResolution + selected = currentResolution == discreteResolution ) } } diff --git a/app/src/main/java/io/github/chrisimx/scanbridge/ScanningScreen.kt b/app/src/main/java/io/github/chrisimx/scanbridge/ScanningScreen.kt index 9db3ff2..a7ce441 100644 --- a/app/src/main/java/io/github/chrisimx/scanbridge/ScanningScreen.kt +++ b/app/src/main/java/io/github/chrisimx/scanbridge/ScanningScreen.kt @@ -611,7 +611,6 @@ fun ScanningScreen( ModalBottomSheet({ scanningViewModel.setScanSettingsMenuOpen(false) }) { ScanSettingsUI( Modifier.heightIn(max = screenHeight * 0.8f), - context, scanningViewModel.scanningScreenData.scanSettingsVM!! ) } diff --git a/app/src/main/java/io/github/chrisimx/scanbridge/data/ui/ScanSettingsComposableData.kt b/app/src/main/java/io/github/chrisimx/scanbridge/data/ui/ScanSettingsComposableData.kt index 95599d7..af2cf70 100644 --- a/app/src/main/java/io/github/chrisimx/scanbridge/data/ui/ScanSettingsComposableData.kt +++ b/app/src/main/java/io/github/chrisimx/scanbridge/data/ui/ScanSettingsComposableData.kt @@ -27,9 +27,10 @@ import kotlinx.serialization.Serializable @Serializable -sealed class NumberValidationError { - data class OutOfRange(val min: Double, val max: Double) : NumberValidationError() - data object NotANumber : NumberValidationError() +sealed class NumberValidationResult { + data class Success(val value: Double) : NumberValidationResult() + data class OutOfRange(val min: Double, val max: Double) : NumberValidationResult() + data object NotANumber : NumberValidationResult() } @Serializable @@ -37,9 +38,8 @@ data class ScanSettingsComposableData( val scanSettings: ScanSettings, val capabilities: ScannerCapabilities, val paperFormats: List = loadDefaultFormats(), - val customMenuEnabled: Boolean, - val widthString: String, - val widthError: NumberValidationError?, - val heightString: String, - val heightError: NumberValidationError? + val customMenuEnabled: Boolean = false, + val widthString: String = "", + val heightString: String = "", + val maximumSize: Boolean = true ) diff --git a/app/src/main/java/io/github/chrisimx/scanbridge/data/ui/ScanSettingsComposableViewModel.kt b/app/src/main/java/io/github/chrisimx/scanbridge/data/ui/ScanSettingsComposableViewModel.kt index 6eefc34..72e96b8 100644 --- a/app/src/main/java/io/github/chrisimx/scanbridge/data/ui/ScanSettingsComposableViewModel.kt +++ b/app/src/main/java/io/github/chrisimx/scanbridge/data/ui/ScanSettingsComposableViewModel.kt @@ -19,34 +19,59 @@ package io.github.chrisimx.scanbridge.data.ui +import android.app.Application +import android.content.ClipData +import android.content.ClipboardManager +import android.content.Context.CLIPBOARD_SERVICE import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import io.github.chrisimx.esclkt.DiscreteResolution import io.github.chrisimx.esclkt.InputSource import io.github.chrisimx.esclkt.InputSourceCaps +import io.github.chrisimx.esclkt.LengthUnit import io.github.chrisimx.esclkt.ScanIntentEnumOrRaw -import io.github.chrisimx.esclkt.ScanRegionLength import io.github.chrisimx.esclkt.ScanSettings +import io.github.chrisimx.esclkt.ThreeHundredthsOfInch import io.github.chrisimx.esclkt.getInputSourceCaps import io.github.chrisimx.esclkt.getInputSourceOptions +import io.github.chrisimx.esclkt.inches import io.github.chrisimx.esclkt.millimeters import io.github.chrisimx.esclkt.scanRegion +import io.github.chrisimx.esclkt.threeHundredthsOfInch +import io.github.chrisimx.scanbridge.R +import io.github.chrisimx.scanbridge.data.ui.ScanSettingsLengthUnit.* +import io.github.chrisimx.scanbridge.services.LocaleProvider import io.github.chrisimx.scanbridge.util.derived import io.github.chrisimx.scanbridge.util.getMaxResolution import io.github.chrisimx.scanbridge.util.toDoubleLocalized import java.util.* import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.filter +import kotlinx.coroutines.flow.filterIsInstance import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.update +import org.koin.core.annotation.InjectedParam + +enum class ScanSettingsLengthUnit { + INCH, + MILLIMETER +} class ScanSettingsComposableViewModel( + @InjectedParam private val initialScanSettingsData: ScanSettingsComposableData, + @InjectedParam private val onSettingsChanged: (() -> Unit)? = null, + private val localeProvider: LocaleProvider, + private val context: Application ) : ViewModel() { private val _uiState = MutableStateFlow(initialScanSettingsData) @@ -60,11 +85,15 @@ class ScanSettingsComposableViewModel( it.capabilities.adf?.duplexCaps != null } + val duplexCurrentlyAvailable: StateFlow = combine(duplexAdfSupported, _uiState) { duplexSupport, uiState -> + duplexSupport && uiState.scanSettings.inputSource == InputSource.Feeder + }.stateIn(viewModelScope, SharingStarted.Lazily, false) + private val selectedInputSourceCaps: StateFlow = _uiState.derived(viewModelScope) { it.capabilities.getInputSourceCaps(it.scanSettings.inputSource, it.scanSettings.duplex ?: false) } - private val intentOptions = selectedInputSourceCaps.derived(viewModelScope) { + val intentOptions = selectedInputSourceCaps.derived(viewModelScope) { it.supportedIntents } @@ -72,6 +101,79 @@ class ScanSettingsComposableViewModel( it.settingProfiles[0].supportedResolutions } + val currentResolution: StateFlow = uiState.derived(viewModelScope) { + val settings = it.scanSettings + val x = settings.xResolution + val y = settings.yResolution + + if (x != null && y != null) DiscreteResolution(x, y) else null + } + + val lengthUnit = localeProvider.locale.derived(viewModelScope) { + unitByLocale(it) + } + + val currentWidthText = _uiState.derived(viewModelScope) { + it.widthString + } + + val currentHeightText = _uiState.derived(viewModelScope) { + it.heightString + } + + val currentScanRegion = _uiState.derived(viewModelScope) { + it.scanSettings.scanRegions?.regions?.firstOrNull() + } + + val heightValidationResult = combine(currentHeightText, lengthUnit, selectedInputSourceCaps) + { heightText, unit, inputSourceCaps -> + return@combine validateCustomLengthInput(heightText, unit, inputSourceCaps.maxHeight, inputSourceCaps.minHeight) + } + + val widthValidationResult = combine(currentWidthText, lengthUnit, selectedInputSourceCaps) + { widthText, unit, inputSourceCaps -> + return@combine validateCustomLengthInput(widthText, unit, inputSourceCaps.maxWidth, inputSourceCaps.minWidth) + } + + private fun validateCustomLengthInput( + lengthText: String, + unit: ScanSettingsLengthUnit, + max: ThreeHundredthsOfInch, + min: ThreeHundredthsOfInch + ): NumberValidationResult { + val parsedLength = runCatching { + lengthText.toDoubleLocalized() + }.getOrNull() + + if (parsedLength == null) { + return NumberValidationResult.NotANumber + } + + val lengthInUnit = when (unit) { + INCH -> parsedLength.inches() + MILLIMETER -> parsedLength.millimeters() + } + + val inputLengthInT300 = lengthInUnit.toThreeHundredthsOfInch().value + + if (inputLengthInT300 in min.value..max.value) { + return NumberValidationResult.Success(inputLengthInT300.toDouble()) + } else { + val maxInUserUnit = toUserUnit(unit, max) + val minInUserUnit = toUserUnit(unit, min) + + return NumberValidationResult.OutOfRange(minInUserUnit, maxInUserUnit) + } + } + + private fun toUserUnit( + unit: ScanSettingsLengthUnit, + length: LengthUnit + ): Double = when (unit) { + INCH -> length.toInches().value + MILLIMETER -> length.toMillimeters().value + } + private inline fun ScanSettingsComposableData.updateScanSettings(update: ScanSettings.() -> ScanSettings) = copy(scanSettings = scanSettings.update()) @@ -87,6 +189,88 @@ class ScanSettingsComposableViewModel( .onEach { onSettingsChanged?.invoke() } .launchIn(viewModelScope) + observeHeightValidation() + observeWidthValidation() + + _uiState + .map { it.maximumSize } + .distinctUntilChanged() + .combine(selectedInputSourceCaps) { maxSize, inputSourceCaps -> Pair(maxSize, inputSourceCaps)} + .filter { it.first } + .onEach { (maxSize, inputSourceCaps) -> + _uiState.updateScanSettings { + copy( + scanRegions = scanRegion { + width = inputSourceCaps.maxWidth + height = inputSourceCaps.maxHeight + xOffset = 0.millimeters() + yOffset = 0.millimeters() + } + ) + } + }.launchIn(viewModelScope) + } + + private fun observeWidthValidation() { + widthValidationResult + .filterIsInstance() + .distinctUntilChanged() + .onEach { widthValidationResult -> + _uiState.update { + val currentScanRegion = it.scanSettings.scanRegions?.regions?.firstOrNull() + + if (currentScanRegion == null) { + return@update it.copy( + scanSettings = it.scanSettings.copy( + scanRegions = scanRegion { + maxHeight() + width = widthValidationResult.value.threeHundredthsOfInch() + } + )) + } else { + val currentHeight = currentScanRegion.height + return@update it.copy( + scanSettings = it.scanSettings.copy( + scanRegions = scanRegion { + width = widthValidationResult.value.threeHundredthsOfInch() + height = currentHeight + } + )) + } + } + } + .launchIn(viewModelScope) + } + + private fun observeHeightValidation() { + heightValidationResult + .filterIsInstance() + .distinctUntilChanged() + .onEach { heightValidationResult -> + _uiState.update { + val currentScanRegion = it.scanSettings.scanRegions?.regions?.firstOrNull() + + if (currentScanRegion == null) { + return@update it.copy( + scanSettings = it.scanSettings.copy( + scanRegions = scanRegion { + maxWidth() + height = heightValidationResult.value.threeHundredthsOfInch() + } + )) + } else { + val currentWidth = currentScanRegion.width + return@update it.copy( + scanSettings = it.scanSettings.copy( + scanRegions = scanRegion { + width = currentWidth + height = heightValidationResult.value.threeHundredthsOfInch() + } + )) + } + } + } + .launchIn(viewModelScope) } fun setDuplex(duplex: Boolean) { @@ -95,7 +279,7 @@ class ScanSettingsComposableViewModel( } } - fun setInputSourceOptions(inputSource: InputSource) { + fun setInputSource(inputSource: InputSource) { _uiState.update { val currentScanSettings = it.scanSettings val inputSourceCaps = it.capabilities.getInputSourceCaps(inputSource) @@ -105,7 +289,7 @@ class ScanSettingsComposableViewModel( val xRes = currentScanSettings.xResolution val yRes = currentScanSettings.yResolution - val validResolutionSetting = xRes != null && yRes != null + val validResolutionSetting = xRes != null && yRes != null && !supportedResolutions.contains(DiscreteResolution(xRes, yRes)) val replacementResolution = if (validResolutionSetting) { @@ -124,11 +308,14 @@ class ScanSettingsComposableViewModel( null } - it.copy(scanSettings = it.scanSettings.copy( - xResolution = replacementResolution.first, - yResolution = replacementResolution.second, - intent = replacementIntent - )) + it.copy( + scanSettings = it.scanSettings.copy( + inputSource = inputSource, + xResolution = replacementResolution.first, + yResolution = replacementResolution.second, + intent = replacementIntent + ) + ) } } @@ -149,55 +336,73 @@ class ScanSettingsComposableViewModel( fun setCustomMenuEnabled(enabled: Boolean) { _uiState.update { - it.copy(customMenuEnabled = enabled) + it.copy( + maximumSize = false, + customMenuEnabled = enabled + ) } } fun setCustomWidthTextFieldContent(width: String) { check(_uiState.value.customMenuEnabled) _uiState.update { - it.copy(widthString = width) + it.copy( + maximumSize = false, + widthString = width + ) } } fun setCustomHeightTextFieldContent(height: String) { check(_uiState.value.customMenuEnabled) - - val parsedHeight = runCatching { - height.toDoubleLocalized() - }.getOrNull() - _uiState.update { - if (parsedHeight == null) { - return@update it.copy( - heightString = height, - heightError = NumberValidationError.NotANumber - ) - } - - val maxHeight = selectedInputSourceCaps.value.maxHeight.toMillimeters().value - val minHeight = selectedInputSourceCaps.value.minHeight.toMillimeters().value - - val scanSettingsWithNewHeight = it.scanSettings.copy(scanRegions = ) + it.copy( + maximumSize = false, + heightString = height + ) + } + } - it.copy(heightString = height, scanSettings = parsedHeight.coerceIn(minHeight, maxHeight)) + private fun unitByLocale(locale: Locale = Locale.getDefault()): ScanSettingsLengthUnit { + return if (locale.country in setOf("US", "LR", "MM")) { + INCH + } else { + MILLIMETER } } - fun usesImperial(locale: Locale = Locale.getDefault()): Boolean { - return locale.country in setOf("US", "LR", "MM") + fun selectMaxRegion() { + _uiState.update { + it.copy(maximumSize = true) + } } - fun setRegionDimension(newWidth: ScanRegionLength, newHeight: ScanRegionLength) { - _uiState.updateScanSettings { - copy( + fun setRegionDimension(newWidth: LengthUnit, newHeight: LengthUnit) { + _uiState.update { + val scanSettings = it.scanSettings.copy( scanRegions = scanRegion { - width(newWidth) - height(newHeight) + width = newWidth + height = newHeight xOffset = 0.millimeters() yOffset = 0.millimeters() } ) + it.copy( + scanSettings = scanSettings, + maximumSize = false + ) } } + + fun copySettingsToClipboard() { + val systemClipboard = + context.getSystemService(CLIPBOARD_SERVICE) as ClipboardManager + val scanSettingsString = uiState.value.scanSettings.toString() + systemClipboard.setPrimaryClip( + ClipData.newPlainText( + context.getString(R.string.scan_settings), + scanSettingsString + ) + ) + } } diff --git a/app/src/main/java/io/github/chrisimx/scanbridge/data/ui/ScanningScreenViewModel.kt b/app/src/main/java/io/github/chrisimx/scanbridge/data/ui/ScanningScreenViewModel.kt index 01bee43..1890285 100644 --- a/app/src/main/java/io/github/chrisimx/scanbridge/data/ui/ScanningScreenViewModel.kt +++ b/app/src/main/java/io/github/chrisimx/scanbridge/data/ui/ScanningScreenViewModel.kt @@ -35,14 +35,15 @@ import io.github.chrisimx.esclkt.JobState import io.github.chrisimx.esclkt.ScanJob import io.github.chrisimx.esclkt.ScanSettings import io.github.chrisimx.esclkt.ScannerCapabilities +import io.github.chrisimx.esclkt.getInputSourceCaps +import io.github.chrisimx.esclkt.getInputSourceOptions import io.github.chrisimx.scanbridge.R import io.github.chrisimx.scanbridge.data.model.Session +import io.github.chrisimx.scanbridge.data.model.StatelessImmutableScanRegion import io.github.chrisimx.scanbridge.stores.DefaultScanSettingsStore import io.github.chrisimx.scanbridge.stores.SessionsStore import io.github.chrisimx.scanbridge.util.calculateDefaultESCLScanSettingsState import io.github.chrisimx.scanbridge.util.getEditedImageName -import io.github.chrisimx.scanbridge.util.getInputSourceCaps -import io.github.chrisimx.scanbridge.util.getInputSourceOptions import io.github.chrisimx.scanbridge.util.rotateBy90 import io.github.chrisimx.scanbridge.util.saveAsJPEG import io.github.chrisimx.scanbridge.util.snackbarErrorRetrievingPage @@ -64,6 +65,9 @@ import kotlinx.coroutines.delay import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import kotlinx.serialization.ExperimentalSerializationApi +import org.koin.core.parameter.parametersOf +import org.koin.core.qualifier.named +import org.koin.mp.KoinPlatform.getKoin import timber.log.Timber class ScanningScreenViewModel( @@ -107,6 +111,16 @@ class ScanningScreenViewModel( val scanningScreenData: ImmutableScanningScreenData get() = _scanningScreenData.toImmutable() + private val childScope = getKoin().createScope( + "scanSettingsScope", + named("ScanSettingsScope") + ) + + override fun onCleared() { + childScope.close() + super.onCleared() + } + fun addTempFile(file: File) { _scanningScreenData.createdTempFiles.add(file) saveSessionFile() @@ -241,51 +255,60 @@ class ScanningScreenViewModel( if (storedSession != null) { scanningScreenData.currentScansState.addAll(storedSession.scannedPages) _scanningScreenData.createdTempFiles.addAll(storedSession.tmpFiles.map { File(it) }) - _scanningScreenData.scanSettingsVM.value = ScanSettingsComposableViewModel( - ScanSettingsComposableData(storedSession.scanSettings?.toMutable() ?: caps.calculateDefaultESCLScanSettingsState(), caps), - onSettingsChanged = { - saveScanSettings() - saveSessionFile() - } - ) + _scanningScreenData.scanSettingsVM.value = childScope.get { + parametersOf( + ScanSettingsComposableData( + storedSession.scanSettings ?: caps.calculateDefaultESCLScanSettingsState(), + caps, + ), + { + saveScanSettings() + saveSessionFile() + } + ) + } } else { // Try to load saved scan settings first, fallback to defaults if none exist val savedSettings = DefaultScanSettingsStore.load(application.applicationContext) val initialSettings = if (savedSettings != null) { try { - val mutableSettings = savedSettings.toMutable() - // Validate that the saved input source is still supported val supportedInputSources = caps.getInputSourceOptions() - if (mutableSettings.inputSource != null && - !supportedInputSources.contains(mutableSettings.inputSource) + val validatedInputSource = if (savedSettings.inputSource != null && + !supportedInputSources.contains(savedSettings.inputSource) ) { Timber.w( - "Saved input source ${mutableSettings.inputSource} not supported by current scanner, falling back to default" + "Saved input source ${savedSettings.inputSource} not supported by current scanner, falling back to default" ) - mutableSettings.inputSource = supportedInputSources.firstOrNull() ?: InputSource.Platen + supportedInputSources.firstOrNull() ?: InputSource.Platen + } else { + savedSettings.inputSource } // Validate duplex setting - only allow if ADF supports duplex - if (mutableSettings.duplex == true && - (mutableSettings.inputSource != InputSource.Feeder || caps.adf?.duplexCaps == null) + val duplex = if (savedSettings.duplex == true && + (savedSettings.inputSource != InputSource.Feeder || caps.adf?.duplexCaps == null) ) { Timber.w("Duplex not supported with current input source, disabling duplex") - mutableSettings.duplex = false + false + } else { + savedSettings.duplex } val selectedInputSourceCaps = caps.getInputSourceCaps( - mutableSettings.inputSource ?: InputSource.Platen, - mutableSettings.duplex ?: false + savedSettings.inputSource ?: InputSource.Platen, + savedSettings.duplex ?: false ) - if (!selectedInputSourceCaps.supportedIntents.contains(mutableSettings.intent)) { - mutableSettings.intent = selectedInputSourceCaps.supportedIntents.first() + val intent = if (!selectedInputSourceCaps.supportedIntents.contains(savedSettings.intent)) { + selectedInputSourceCaps.supportedIntents.first() + } else { + savedSettings.intent } - if (mutableSettings.scanRegions != null) { - val storedWidthThreeHOfInch = mutableSettings.scanRegions!!.width.toDoubleOrNull() - val storedHeightThreeHOfInch = mutableSettings.scanRegions!!.height.toDoubleOrNull() + val scanRegion = if (savedSettings.scanRegions != null) { + val storedWidthThreeHOfInch = savedSettings.scanRegions.width.toDoubleOrNull() + val storedHeightThreeHOfInch = savedSettings.scanRegions.height.toDoubleOrNull() val maxWidth = selectedInputSourceCaps.maxWidth.toMillimeters().value val minWidth = selectedInputSourceCaps.minWidth.toMillimeters().value @@ -293,19 +316,35 @@ class ScanningScreenViewModel( val maxHeight = selectedInputSourceCaps.maxHeight.toMillimeters().value val minHeight = selectedInputSourceCaps.minHeight.toMillimeters().value - if (storedWidthThreeHOfInch != null && + val width = if (storedWidthThreeHOfInch != null && (storedWidthThreeHOfInch > maxWidth || storedWidthThreeHOfInch < minWidth) ) { - mutableSettings.scanRegions!!.width = "max" + "max" + } else { + savedSettings.scanRegions.width } - if (storedHeightThreeHOfInch != null && + val height = if (storedHeightThreeHOfInch != null && (storedHeightThreeHOfInch > maxHeight || storedHeightThreeHOfInch < minHeight) ) { - mutableSettings.scanRegions!!.height = "max" + "max" + } else { + savedSettings.scanRegions.height } + val xOffset = savedSettings.scanRegions.xOffset + val yOffset = savedSettings.scanRegions.yOffset + StatelessImmutableScanRegion(height, width, xOffset,yOffset) + } else { + null } - mutableSettings + val validatedSettings = savedSettings.copy( + inputSource = validatedInputSource, + duplex = duplex, + intent = intent, + scanRegions = scanRegion + ) + + validatedSettings.toESCLKtScanSettings(selectedInputSourceCaps) } catch (e: Exception) { Timber.e(e, "Error applying saved settings, using defaults") caps.calculateDefaultESCLScanSettingsState() @@ -314,16 +353,18 @@ class ScanningScreenViewModel( caps.calculateDefaultESCLScanSettingsState() } - _scanningScreenData.scanSettingsVM.value = ScanSettingsComposableViewModel( - ScanSettingsComposableData( - initialSettings, - caps - ), - onSettingsChanged = { - saveScanSettings() - saveSessionFile() - } - ) + _scanningScreenData.scanSettingsVM.value = childScope.get { + parametersOf( + ScanSettingsComposableData( + initialSettings, + caps, + ), + { + saveScanSettings() + saveSessionFile() + } + ) + } val sessionFile = application.applicationInfo.dataDir + "/files/" + scanningScreenData.sessionID + ".session" addTempFile(File(sessionFile)) saveSessionFile() @@ -344,7 +385,7 @@ class ScanningScreenViewModel( val currentSessionState = Session( scanningScreenData.sessionID, scanningScreenData.currentScansState.toList(), - scanningScreenData.scanSettingsVM?.getMutableScanSettingsComposableData()?.scanSettingsState?.toStateless(), + scanningScreenData.scanSettingsVM?.uiState?.value?.scanSettings, scanningScreenData.createdTempFiles.map { it.absolutePath } ) return SessionsStore.saveSession(currentSessionState, application, scanningScreenData.sessionID) @@ -377,7 +418,7 @@ class ScanningScreenViewModel( } fun saveScanSettings() { - scanningScreenData.scanSettingsVM?.getMutableScanSettingsComposableData()?.scanSettingsState?.toStateless()?.let { settings -> + scanningScreenData.scanSettingsVM?.uiState?.value?.scanSettings?.let { settings -> DefaultScanSettingsStore.save(application.applicationContext, settings) Timber.d("Scan settings saved to persistent storage") } @@ -408,12 +449,8 @@ class ScanningScreenViewModel( return } - val scanSettingsData = - scanningScreenData.scanSettingsVM!!.scanSettingsComposableData - - val currentScanSettings = scanSettingsData.scanSettingsState.toESCLKtScanSettings( - scanSettingsData.selectedInputSourceCapabilities - ) + val currentScanSettings = + scanningScreenData.scanSettingsVM!!.uiState.value.scanSettings val esclRequestClient = _scanningScreenData.esclClient diff --git a/app/src/main/java/io/github/chrisimx/scanbridge/util/LocaleProvider.kt b/app/src/main/java/io/github/chrisimx/scanbridge/services/AndroidLocaleProvider.kt similarity index 63% rename from app/src/main/java/io/github/chrisimx/scanbridge/util/LocaleProvider.kt rename to app/src/main/java/io/github/chrisimx/scanbridge/services/AndroidLocaleProvider.kt index c8321b6..cfbeba4 100644 --- a/app/src/main/java/io/github/chrisimx/scanbridge/util/LocaleProvider.kt +++ b/app/src/main/java/io/github/chrisimx/scanbridge/services/AndroidLocaleProvider.kt @@ -1,20 +1,20 @@ -package io.github.chrisimx.scanbridge.util +package io.github.chrisimx.scanbridge.services import android.annotation.SuppressLint -import java.util.* import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow import timber.log.Timber +import java.util.Locale -object LocaleProvider { +class AndroidLocaleProvider : LocaleProvider { @SuppressLint("ConstantLocale") private val _locale = MutableStateFlow(Locale.getDefault()) - val locale: StateFlow = _locale.asStateFlow() + override val locale: StateFlow = _locale.asStateFlow() internal fun update() { val locale = Locale.getDefault() - Timber.d("Locale updated to $locale") + Timber.Forest.d("Locale updated to $locale") _locale.value = locale } } diff --git a/app/src/main/java/io/github/chrisimx/scanbridge/services/LocaleProvider.kt b/app/src/main/java/io/github/chrisimx/scanbridge/services/LocaleProvider.kt new file mode 100644 index 0000000..4599f38 --- /dev/null +++ b/app/src/main/java/io/github/chrisimx/scanbridge/services/LocaleProvider.kt @@ -0,0 +1,9 @@ +package io.github.chrisimx.scanbridge.services + +import java.util.Locale +import kotlinx.coroutines.flow.StateFlow + +interface LocaleProvider { + val locale: StateFlow +} + diff --git a/app/src/main/java/io/github/chrisimx/scanbridge/stores/DefaultScanSettingsStore.kt b/app/src/main/java/io/github/chrisimx/scanbridge/stores/DefaultScanSettingsStore.kt index 0bf1d1b..2f37960 100644 --- a/app/src/main/java/io/github/chrisimx/scanbridge/stores/DefaultScanSettingsStore.kt +++ b/app/src/main/java/io/github/chrisimx/scanbridge/stores/DefaultScanSettingsStore.kt @@ -21,6 +21,7 @@ package io.github.chrisimx.scanbridge.stores import android.content.Context import androidx.core.content.edit +import io.github.chrisimx.esclkt.ScanSettings import io.github.chrisimx.scanbridge.data.model.StatelessImmutableESCLScanSettingsState import kotlinx.serialization.json.Json import timber.log.Timber @@ -33,7 +34,7 @@ object DefaultScanSettingsStore { return appPreferences.getBoolean("remember_scan_settings", true) } - fun save(context: Context, scanSettings: StatelessImmutableESCLScanSettingsState) { + fun save(context: Context, scanSettings: ScanSettings) { if (!isRememberSettingsEnabled(context)) { Timber.d("Scan settings persistence is disabled, skipping save") return diff --git a/app/src/main/java/io/github/chrisimx/scanbridge/uicomponents/ValidatedTextField.kt b/app/src/main/java/io/github/chrisimx/scanbridge/uicomponents/ValidatedTextField.kt index e78197d..7174033 100644 --- a/app/src/main/java/io/github/chrisimx/scanbridge/uicomponents/ValidatedTextField.kt +++ b/app/src/main/java/io/github/chrisimx/scanbridge/uicomponents/ValidatedTextField.kt @@ -31,72 +31,41 @@ import androidx.compose.runtime.remember import androidx.compose.ui.Modifier import androidx.compose.ui.text.input.KeyboardType import io.github.chrisimx.scanbridge.R +import io.github.chrisimx.scanbridge.data.ui.NumberValidationResult import io.github.chrisimx.scanbridge.util.toDoubleLocalized import timber.log.Timber - -enum class ErrorState { - NOT_WITHIN_ALLOWED_RANGE, - NOT_VALID_NUMBER, - NO_ERROR -} - -fun ErrorState.toHumanString(context: Context): String = when (this) { - ErrorState.NOT_WITHIN_ALLOWED_RANGE -> context.getString(R.string.error_state_not_in_allowed_range) - ErrorState.NOT_VALID_NUMBER -> context.getString(R.string.error_state_not_a_valid_number) - ErrorState.NO_ERROR -> context.getString(R.string.error_state_valid) +fun NumberValidationResult.toHumanString(context: Context): String = when (this) { + is NumberValidationResult.OutOfRange -> context.getString(R.string.error_state_not_in_allowed_range) + NumberValidationResult.NotANumber -> context.getString(R.string.error_state_not_a_valid_number) + is NumberValidationResult.Success -> context.getString(R.string.error_state_valid) } @Composable fun ValidatedDimensionsTextEdit( - localContent: String, + text: String, context: Context, modifier: Modifier = Modifier, label: String, updateContent: (String) -> Unit, - updateDimensionState: (String) -> Unit, - min: Double, - max: Double + validationResult: NumberValidationResult ) { - val errorState = remember { mutableStateOf(ErrorState.NO_ERROR) } - - val decimalSeparator = DecimalFormatSymbols.getInstance().decimalSeparator - val decimalNumberRegex = - "^[+]?\\d*(${Regex.escape(decimalSeparator.toString())})?\\d+\$".toRegex() - OutlinedTextField( modifier = modifier, - value = localContent, + value = text, onValueChange = { newValue: String -> updateContent(newValue) - - val isValidNumber = newValue.matches(decimalNumberRegex) - if (isValidNumber) { - val newNumber = newValue.toDoubleLocalized() - if (newNumber > max || newNumber < min) { - errorState.value = ErrorState.NOT_WITHIN_ALLOWED_RANGE - return@OutlinedTextField - } - errorState.value = ErrorState.NO_ERROR - updateDimensionState(newValue) - - return@OutlinedTextField - } else { - errorState.value = ErrorState.NOT_VALID_NUMBER - Timber.tag("ScanSettings").d("Invalid Number") - return@OutlinedTextField - } }, supportingText = { - if (errorState.value != ErrorState.NO_ERROR) { + if (validationResult !is NumberValidationResult.Success) { Text( - errorState.value.toHumanString( + validationResult.toHumanString( context ), style = MaterialTheme.typography.labelSmall ) } }, - isError = errorState.value != ErrorState.NO_ERROR, + isError = validationResult !is NumberValidationResult.Success, keyboardOptions = KeyboardOptions.Default.copy( keyboardType = KeyboardType.Decimal ), diff --git a/app/src/main/res/values-de/strings.xml b/app/src/main/res/values-de/strings.xml index dafb77c..eb34323 100644 --- a/app/src/main/res/values-de/strings.xml +++ b/app/src/main/res/values-de/strings.xml @@ -39,8 +39,8 @@ Auflösung (DPI): Duplex Standard - Breite (in mm) - Höhe (in mm) + Breite (in mm) + Höhe (in mm) Maximum Benutzerdefiniert Ein Scanauftrag läuft bereits diff --git a/app/src/main/res/values-it/strings.xml b/app/src/main/res/values-it/strings.xml index b543729..f531522 100644 --- a/app/src/main/res/values-it/strings.xml +++ b/app/src/main/res/values-it/strings.xml @@ -37,8 +37,8 @@ Risoluzione utilizzata (dpi): Duplex Predefinito - Larghezza (in mm) - Altezza (in mm) + Larghezza (in mm) + Altezza (in mm) Dimensione massima Personalizzato Attività ancora in esecuzione diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 01fbc5a..0417f55 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -39,8 +39,8 @@ Used Resolution (dpi): Duplex Default - Width (in mm) - Height (in mm) + Width (in %1$s) + Height (in %1$s) Maximum size Custom Job still running @@ -111,4 +111,6 @@ "We are sorry. ScanBridge has crashed. Please report this issue and attach the following stacktrace (you can copy it with the button at the bottom)" Loading previous session failed…\n\n%1$s The scanner capabilities should be retrieved at this point, but they weren\'t. + millimeter_unit_abreviation + Inches \ No newline at end of file diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 6a37915..f089b39 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -4,7 +4,7 @@ coilCompose = "3.3.0" constraintlayoutCompose = "1.1.1" esclkt = "2.0.5-SNAPSHOT" itextCore = "9.3.0" -kotlin = "2.2.20" +kotlin = "2.3.20-Beta1" coreKtx = "1.17.0" junit = "4.13.2" junitVersion = "1.3.0" @@ -22,8 +22,29 @@ navigationCompose = "2.9.5" versionsPlugin = "0.53.0" screengrab = "2.1.1" ktor = "3.4.0" +koin = "4.2.0-RC1" +koin-plugin = "0.3.0" [libraries] + +koin-core = { module = "io.insert-koin:koin-core", version.ref = "koin" } +koin-annotations = { module = "io.insert-koin:koin-annotations", version.ref = "koin" } +koin-test = { module = "io.insert-koin:koin-test", version.ref = "koin" } +koin-test-junit4 = { module = "io.insert-koin:koin-test-junit4", version.ref = "koin" } + +# Android +koin-android = { module = "io.insert-koin:koin-android", version.ref = "koin" } +koin-android-test = { module = "io.insert-koin:koin-android-test", version.ref = "koin" } + +koin-compose = { module = "io.insert-koin:koin-compose", version.ref = "koin"} + +koin-androix-navigation = { module = "io.insert-koin:koin-androidx-compose-navigation", version.ref = "koin"} + +# Compose +koin-compose-viewmodel = { module = "io.insert-koin:koin-compose-viewmodel", version.ref = "koin" } +koin-compose-viewmodel-navigation = { module = "io.insert-koin:koin-compose-viewmodel-navigation", version.ref = "koin" } +koin-androidx-compose = { module = "io.insert-koin:koin-androidx-compose", version.ref = "koin" } + ktor-cio = { module = "io.ktor:ktor-client-cio", version.ref = "ktor"} ktor-logging = { module = "io.ktor:ktor-client-logging", version.ref = "ktor"} androidx-constraintlayout-compose = { module = "androidx.constraintlayout:constraintlayout-compose", version.ref = "constraintlayoutCompose" } @@ -59,3 +80,4 @@ kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" } kotlin-compose = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" } kotlin-serialization = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "kotlin" } versions = { id = "com.github.ben-manes.versions", version.ref = "versionsPlugin" } +koin = { id = "io.insert-koin.compiler.plugin", version.ref = "koin-plugin" } \ No newline at end of file From 0699c030eb2708463b931c1a2a883e43afadd3f8 Mon Sep 17 00:00:00 2001 From: Christian Nagel Date: Fri, 20 Feb 2026 19:26:15 +0100 Subject: [PATCH 04/16] Apply format --- .../chrisimx/scanbridge/ScanSettings.kt | 15 +++--- .../data/model/LegacyESCLScanSettings.kt | 2 - .../data/ui/ScanSettingsComposableData.kt | 1 - .../ui/ScanSettingsComposableViewModel.kt | 49 +++++++++---------- .../data/ui/ScanningScreenViewModel.kt | 9 ++-- .../services/AndroidLocaleProvider.kt | 2 +- .../scanbridge/services/LocaleProvider.kt | 1 - .../uicomponents/ValidatedTextField.kt | 5 -- .../scanbridge/util/ESCLKtExtensions.kt | 3 -- .../scanbridge/util/StateFlowExtensions.kt | 5 +- 10 files changed, 39 insertions(+), 53 deletions(-) diff --git a/app/src/main/java/io/github/chrisimx/scanbridge/ScanSettings.kt b/app/src/main/java/io/github/chrisimx/scanbridge/ScanSettings.kt index 137c243..9f3a244 100644 --- a/app/src/main/java/io/github/chrisimx/scanbridge/ScanSettings.kt +++ b/app/src/main/java/io/github/chrisimx/scanbridge/ScanSettings.kt @@ -71,11 +71,11 @@ import timber.log.Timber ) private val TAG = "ScanSettings" - @OptIn(ExperimentalLayoutApi::class, ExperimentalMaterial3ExpressiveApi::class) @Composable -fun ScanSettingsUI(modifier: Modifier, - scanSettingsViewModel: ScanSettingsComposableViewModel = koinViewModel() +fun ScanSettingsUI( + modifier: Modifier, + scanSettingsViewModel: ScanSettingsComposableViewModel = koinViewModel() ) { val context = LocalContext.current val vmData by scanSettingsViewModel.uiState.collectAsState() @@ -211,14 +211,15 @@ fun ScanSettingsUI(modifier: Modifier, onClick = { scanSettingsViewModel.setCustomMenuEnabled(false) scanSettingsViewModel.setRegionDimension( - paperFormat.width, paperFormat.height + paperFormat.width, + paperFormat.height ) Timber.tag(TAG).d("New region state: ${vmData.scanSettings.scanRegions}") }, label = { Text(paperFormat.name) }, - selected = !vmData.customMenuEnabled && !vmData.maximumSize - && currentScanRegion?.width?.equalsLength(paperFormat.width) == true - && currentScanRegion?.height?.equalsLength(paperFormat.height) == true + selected = !vmData.customMenuEnabled && !vmData.maximumSize && + currentScanRegion?.width?.equalsLength(paperFormat.width) == true && + currentScanRegion?.height?.equalsLength(paperFormat.height) == true ) } InputChip( diff --git a/app/src/main/java/io/github/chrisimx/scanbridge/data/model/LegacyESCLScanSettings.kt b/app/src/main/java/io/github/chrisimx/scanbridge/data/model/LegacyESCLScanSettings.kt index 493ac94..a3eeb60 100644 --- a/app/src/main/java/io/github/chrisimx/scanbridge/data/model/LegacyESCLScanSettings.kt +++ b/app/src/main/java/io/github/chrisimx/scanbridge/data/model/LegacyESCLScanSettings.kt @@ -16,7 +16,6 @@ import io.github.chrisimx.esclkt.millimeters import io.github.chrisimx.scanbridge.util.toDoubleLocalized import kotlinx.serialization.Serializable - @Serializable data class StatelessImmutableScanRegion( // These are to be given in millimeters! @@ -43,7 +42,6 @@ data class StatelessImmutableScanRegion( yOffset.toDoubleLocalized().millimeters().toThreeHundredthsOfInch() ) } - } @Serializable diff --git a/app/src/main/java/io/github/chrisimx/scanbridge/data/ui/ScanSettingsComposableData.kt b/app/src/main/java/io/github/chrisimx/scanbridge/data/ui/ScanSettingsComposableData.kt index af2cf70..843cb41 100644 --- a/app/src/main/java/io/github/chrisimx/scanbridge/data/ui/ScanSettingsComposableData.kt +++ b/app/src/main/java/io/github/chrisimx/scanbridge/data/ui/ScanSettingsComposableData.kt @@ -25,7 +25,6 @@ import io.github.chrisimx.scanbridge.data.model.PaperFormat import io.github.chrisimx.scanbridge.data.model.loadDefaultFormats import kotlinx.serialization.Serializable - @Serializable sealed class NumberValidationResult { data class Success(val value: Double) : NumberValidationResult() diff --git a/app/src/main/java/io/github/chrisimx/scanbridge/data/ui/ScanSettingsComposableViewModel.kt b/app/src/main/java/io/github/chrisimx/scanbridge/data/ui/ScanSettingsComposableViewModel.kt index 72e96b8..32eef03 100644 --- a/app/src/main/java/io/github/chrisimx/scanbridge/data/ui/ScanSettingsComposableViewModel.kt +++ b/app/src/main/java/io/github/chrisimx/scanbridge/data/ui/ScanSettingsComposableViewModel.kt @@ -39,7 +39,6 @@ import io.github.chrisimx.esclkt.millimeters import io.github.chrisimx.esclkt.scanRegion import io.github.chrisimx.esclkt.threeHundredthsOfInch import io.github.chrisimx.scanbridge.R -import io.github.chrisimx.scanbridge.data.ui.ScanSettingsLengthUnit.* import io.github.chrisimx.scanbridge.services.LocaleProvider import io.github.chrisimx.scanbridge.util.derived import io.github.chrisimx.scanbridge.util.getMaxResolution @@ -126,14 +125,14 @@ class ScanSettingsComposableViewModel( } val heightValidationResult = combine(currentHeightText, lengthUnit, selectedInputSourceCaps) - { heightText, unit, inputSourceCaps -> - return@combine validateCustomLengthInput(heightText, unit, inputSourceCaps.maxHeight, inputSourceCaps.minHeight) - } + { heightText, unit, inputSourceCaps -> + return@combine validateCustomLengthInput(heightText, unit, inputSourceCaps.maxHeight, inputSourceCaps.minHeight) + } val widthValidationResult = combine(currentWidthText, lengthUnit, selectedInputSourceCaps) - { widthText, unit, inputSourceCaps -> - return@combine validateCustomLengthInput(widthText, unit, inputSourceCaps.maxWidth, inputSourceCaps.minWidth) - } + { widthText, unit, inputSourceCaps -> + return@combine validateCustomLengthInput(widthText, unit, inputSourceCaps.maxWidth, inputSourceCaps.minWidth) + } private fun validateCustomLengthInput( lengthText: String, @@ -150,8 +149,8 @@ class ScanSettingsComposableViewModel( } val lengthInUnit = when (unit) { - INCH -> parsedLength.inches() - MILLIMETER -> parsedLength.millimeters() + ScanSettingsLengthUnit.INCH -> parsedLength.inches() + ScanSettingsLengthUnit.MILLIMETER -> parsedLength.millimeters() } val inputLengthInT300 = lengthInUnit.toThreeHundredthsOfInch().value @@ -166,12 +165,9 @@ class ScanSettingsComposableViewModel( } } - private fun toUserUnit( - unit: ScanSettingsLengthUnit, - length: LengthUnit - ): Double = when (unit) { - INCH -> length.toInches().value - MILLIMETER -> length.toMillimeters().value + private fun toUserUnit(unit: ScanSettingsLengthUnit, length: LengthUnit): Double = when (unit) { + ScanSettingsLengthUnit.INCH -> length.toInches().value + ScanSettingsLengthUnit.MILLIMETER -> length.toMillimeters().value } private inline fun ScanSettingsComposableData.updateScanSettings(update: ScanSettings.() -> ScanSettings) = @@ -195,7 +191,7 @@ class ScanSettingsComposableViewModel( _uiState .map { it.maximumSize } .distinctUntilChanged() - .combine(selectedInputSourceCaps) { maxSize, inputSourceCaps -> Pair(maxSize, inputSourceCaps)} + .combine(selectedInputSourceCaps) { maxSize, inputSourceCaps -> Pair(maxSize, inputSourceCaps) } .filter { it.first } .onEach { (maxSize, inputSourceCaps) -> _uiState.updateScanSettings { @@ -226,7 +222,8 @@ class ScanSettingsComposableViewModel( maxHeight() width = widthValidationResult.value.threeHundredthsOfInch() } - )) + ) + ) } else { val currentHeight = currentScanRegion.height return@update it.copy( @@ -235,7 +232,8 @@ class ScanSettingsComposableViewModel( width = widthValidationResult.value.threeHundredthsOfInch() height = currentHeight } - )) + ) + ) } } } @@ -257,7 +255,8 @@ class ScanSettingsComposableViewModel( maxWidth() height = heightValidationResult.value.threeHundredthsOfInch() } - )) + ) + ) } else { val currentWidth = currentScanRegion.width return@update it.copy( @@ -266,7 +265,8 @@ class ScanSettingsComposableViewModel( width = currentWidth height = heightValidationResult.value.threeHundredthsOfInch() } - )) + ) + ) } } } @@ -289,8 +289,8 @@ class ScanSettingsComposableViewModel( val xRes = currentScanSettings.xResolution val yRes = currentScanSettings.yResolution - val validResolutionSetting = xRes != null && yRes != null - && !supportedResolutions.contains(DiscreteResolution(xRes, yRes)) + val validResolutionSetting = xRes != null && yRes != null && + !supportedResolutions.contains(DiscreteResolution(xRes, yRes)) val replacementResolution = if (validResolutionSetting) { val highestScanResolution = it.capabilities.getMaxResolution(inputSource) @@ -363,13 +363,12 @@ class ScanSettingsComposableViewModel( } } - private fun unitByLocale(locale: Locale = Locale.getDefault()): ScanSettingsLengthUnit { - return if (locale.country in setOf("US", "LR", "MM")) { + private fun unitByLocale(locale: Locale = Locale.getDefault()): ScanSettingsLengthUnit = + if (locale.country in setOf("US", "LR", "MM")) { INCH } else { MILLIMETER } - } fun selectMaxRegion() { _uiState.update { diff --git a/app/src/main/java/io/github/chrisimx/scanbridge/data/ui/ScanningScreenViewModel.kt b/app/src/main/java/io/github/chrisimx/scanbridge/data/ui/ScanningScreenViewModel.kt index 1890285..78c6181 100644 --- a/app/src/main/java/io/github/chrisimx/scanbridge/data/ui/ScanningScreenViewModel.kt +++ b/app/src/main/java/io/github/chrisimx/scanbridge/data/ui/ScanningScreenViewModel.kt @@ -259,7 +259,7 @@ class ScanningScreenViewModel( parametersOf( ScanSettingsComposableData( storedSession.scanSettings ?: caps.calculateDefaultESCLScanSettingsState(), - caps, + caps ), { saveScanSettings() @@ -332,7 +332,7 @@ class ScanningScreenViewModel( } val xOffset = savedSettings.scanRegions.xOffset val yOffset = savedSettings.scanRegions.yOffset - StatelessImmutableScanRegion(height, width, xOffset,yOffset) + StatelessImmutableScanRegion(height, width, xOffset, yOffset) } else { null } @@ -357,7 +357,7 @@ class ScanningScreenViewModel( parametersOf( ScanSettingsComposableData( initialSettings, - caps, + caps ), { saveScanSettings() @@ -392,7 +392,8 @@ class ScanningScreenViewModel( } @OptIn(ExperimentalSerializationApi::class) - fun loadSessionFile(caps: ScannerCapabilities): Result = SessionsStore.loadSession(application, scanningScreenData.sessionID, caps) + fun loadSessionFile(caps: ScannerCapabilities): Result = + SessionsStore.loadSession(application, scanningScreenData.sessionID, caps) fun swapTwoPages(index1: Int, index2: Int) { if (index1 < 0 || diff --git a/app/src/main/java/io/github/chrisimx/scanbridge/services/AndroidLocaleProvider.kt b/app/src/main/java/io/github/chrisimx/scanbridge/services/AndroidLocaleProvider.kt index cfbeba4..b168cb8 100644 --- a/app/src/main/java/io/github/chrisimx/scanbridge/services/AndroidLocaleProvider.kt +++ b/app/src/main/java/io/github/chrisimx/scanbridge/services/AndroidLocaleProvider.kt @@ -1,11 +1,11 @@ package io.github.chrisimx.scanbridge.services import android.annotation.SuppressLint +import java.util.Locale import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow import timber.log.Timber -import java.util.Locale class AndroidLocaleProvider : LocaleProvider { @SuppressLint("ConstantLocale") diff --git a/app/src/main/java/io/github/chrisimx/scanbridge/services/LocaleProvider.kt b/app/src/main/java/io/github/chrisimx/scanbridge/services/LocaleProvider.kt index 4599f38..8aef6fd 100644 --- a/app/src/main/java/io/github/chrisimx/scanbridge/services/LocaleProvider.kt +++ b/app/src/main/java/io/github/chrisimx/scanbridge/services/LocaleProvider.kt @@ -6,4 +6,3 @@ import kotlinx.coroutines.flow.StateFlow interface LocaleProvider { val locale: StateFlow } - diff --git a/app/src/main/java/io/github/chrisimx/scanbridge/uicomponents/ValidatedTextField.kt b/app/src/main/java/io/github/chrisimx/scanbridge/uicomponents/ValidatedTextField.kt index 7174033..88ea65b 100644 --- a/app/src/main/java/io/github/chrisimx/scanbridge/uicomponents/ValidatedTextField.kt +++ b/app/src/main/java/io/github/chrisimx/scanbridge/uicomponents/ValidatedTextField.kt @@ -20,20 +20,15 @@ package io.github.chrisimx.scanbridge.uicomponents import android.content.Context -import android.icu.text.DecimalFormatSymbols import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.material3.MaterialTheme import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.Text import androidx.compose.runtime.Composable -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember import androidx.compose.ui.Modifier import androidx.compose.ui.text.input.KeyboardType import io.github.chrisimx.scanbridge.R import io.github.chrisimx.scanbridge.data.ui.NumberValidationResult -import io.github.chrisimx.scanbridge.util.toDoubleLocalized -import timber.log.Timber fun NumberValidationResult.toHumanString(context: Context): String = when (this) { is NumberValidationResult.OutOfRange -> context.getString(R.string.error_state_not_in_allowed_range) NumberValidationResult.NotANumber -> context.getString(R.string.error_state_not_a_valid_number) diff --git a/app/src/main/java/io/github/chrisimx/scanbridge/util/ESCLKtExtensions.kt b/app/src/main/java/io/github/chrisimx/scanbridge/util/ESCLKtExtensions.kt index 0798e99..4eab94f 100644 --- a/app/src/main/java/io/github/chrisimx/scanbridge/util/ESCLKtExtensions.kt +++ b/app/src/main/java/io/github/chrisimx/scanbridge/util/ESCLKtExtensions.kt @@ -20,9 +20,7 @@ package io.github.chrisimx.scanbridge.util import android.content.Context -import android.icu.number.NumberFormatter import android.icu.text.DecimalFormat -import androidx.compose.runtime.mutableStateOf import io.github.chrisimx.esclkt.ColorModeEnumOrRaw import io.github.chrisimx.esclkt.DiscreteResolution import io.github.chrisimx.esclkt.EnumOrRaw @@ -48,7 +46,6 @@ fun String.toDoubleLocalized(): Double = DecimalFormat.getInstance().parse(this) fun Double.toStringLocalized(): String = DecimalFormat.getInstance().format(this) - fun InputSource.toReadableString(context: Context): String = when (this) { InputSource.Platen -> context.getString(R.string.platen) InputSource.Feeder -> context.getString(R.string.adf) diff --git a/app/src/main/java/io/github/chrisimx/scanbridge/util/StateFlowExtensions.kt b/app/src/main/java/io/github/chrisimx/scanbridge/util/StateFlowExtensions.kt index 5cf0736..e7f86ae 100644 --- a/app/src/main/java/io/github/chrisimx/scanbridge/util/StateFlowExtensions.kt +++ b/app/src/main/java/io/github/chrisimx/scanbridge/util/StateFlowExtensions.kt @@ -6,10 +6,7 @@ import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.stateIn -fun StateFlow.derived( - scope: CoroutineScope, - mapper: (T) -> R -): StateFlow = map(mapper) +fun StateFlow.derived(scope: CoroutineScope, mapper: (T) -> R): StateFlow = map(mapper) .stateIn( scope = scope, started = SharingStarted.Lazily, From 4e7a0a98374e2b4817aa8e1262ff0bbd40982ff6 Mon Sep 17 00:00:00 2001 From: Christian Nagel Date: Fri, 20 Feb 2026 19:34:48 +0100 Subject: [PATCH 05/16] Fix missing qualification for enum names --- .../scanbridge/data/ui/ScanSettingsComposableViewModel.kt | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/app/src/main/java/io/github/chrisimx/scanbridge/data/ui/ScanSettingsComposableViewModel.kt b/app/src/main/java/io/github/chrisimx/scanbridge/data/ui/ScanSettingsComposableViewModel.kt index 32eef03..099063f 100644 --- a/app/src/main/java/io/github/chrisimx/scanbridge/data/ui/ScanSettingsComposableViewModel.kt +++ b/app/src/main/java/io/github/chrisimx/scanbridge/data/ui/ScanSettingsComposableViewModel.kt @@ -365,9 +365,9 @@ class ScanSettingsComposableViewModel( private fun unitByLocale(locale: Locale = Locale.getDefault()): ScanSettingsLengthUnit = if (locale.country in setOf("US", "LR", "MM")) { - INCH + ScanSettingsLengthUnit.INCH } else { - MILLIMETER + ScanSettingsLengthUnit.MILLIMETER } fun selectMaxRegion() { From 6c2153554c12a1619bb88c2a6d65f797b0241096 Mon Sep 17 00:00:00 2001 From: Christian Nagel Date: Fri, 20 Feb 2026 23:28:17 +0100 Subject: [PATCH 06/16] Jump to the right page when starting scan --- .../chrisimx/scanbridge/data/ui/ScanningScreenViewModel.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/src/main/java/io/github/chrisimx/scanbridge/data/ui/ScanningScreenViewModel.kt b/app/src/main/java/io/github/chrisimx/scanbridge/data/ui/ScanningScreenViewModel.kt index 78c6181..56ad4d0 100644 --- a/app/src/main/java/io/github/chrisimx/scanbridge/data/ui/ScanningScreenViewModel.kt +++ b/app/src/main/java/io/github/chrisimx/scanbridge/data/ui/ScanningScreenViewModel.kt @@ -459,7 +459,7 @@ class ScanningScreenViewModel( setScanJobCancelling(false) scrollToPage( scope = snackBarScope, - pageNr = scanningScreenData.currentScansState.size + pageNr = scanningScreenData.currentScansState.size + 1 ) if (abortIfCancelling()) return From 6905141fa3bcae7998d9605b56cd53321588dd55 Mon Sep 17 00:00:00 2001 From: Christian Nagel Date: Fri, 20 Feb 2026 23:30:56 +0100 Subject: [PATCH 07/16] Correct name for mm string resource --- app/src/main/res/values/strings.xml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 0417f55..3a367ca 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -111,6 +111,6 @@ "We are sorry. ScanBridge has crashed. Please report this issue and attach the following stacktrace (you can copy it with the button at the bottom)" Loading previous session failed…\n\n%1$s The scanner capabilities should be retrieved at this point, but they weren\'t. - millimeter_unit_abreviation + mm Inches \ No newline at end of file From 1d2d34ec7e788e6d70c8869778ac1ab90e1dc1f7 Mon Sep 17 00:00:00 2001 From: Christian Nagel Date: Fri, 20 Feb 2026 23:31:57 +0100 Subject: [PATCH 08/16] Add placeholder for current unit to string resources --- app/src/main/res/values-de/strings.xml | 4 ++-- app/src/main/res/values-it/strings.xml | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/app/src/main/res/values-de/strings.xml b/app/src/main/res/values-de/strings.xml index eb34323..71dbb63 100644 --- a/app/src/main/res/values-de/strings.xml +++ b/app/src/main/res/values-de/strings.xml @@ -39,8 +39,8 @@ Auflösung (DPI): Duplex Standard - Breite (in mm) - Höhe (in mm) + Breite (in %1$s) + Höhe (in %1$s) Maximum Benutzerdefiniert Ein Scanauftrag läuft bereits diff --git a/app/src/main/res/values-it/strings.xml b/app/src/main/res/values-it/strings.xml index f531522..c24703a 100644 --- a/app/src/main/res/values-it/strings.xml +++ b/app/src/main/res/values-it/strings.xml @@ -37,8 +37,8 @@ Risoluzione utilizzata (dpi): Duplex Predefinito - Larghezza (in mm) - Altezza (in mm) + Larghezza (in %1$s) + Altezza (in %1$s) Dimensione massima Personalizzato Attività ancora in esecuzione From 7c54c7bdb215d0d7cf099d0c7da02d9d1dbeb8f9 Mon Sep 17 00:00:00 2001 From: Christian Nagel Date: Fri, 20 Feb 2026 23:32:41 +0100 Subject: [PATCH 09/16] Correct name for mm string resource --- app/src/main/java/io/github/chrisimx/scanbridge/ScanSettings.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/src/main/java/io/github/chrisimx/scanbridge/ScanSettings.kt b/app/src/main/java/io/github/chrisimx/scanbridge/ScanSettings.kt index 9f3a244..819b44a 100644 --- a/app/src/main/java/io/github/chrisimx/scanbridge/ScanSettings.kt +++ b/app/src/main/java/io/github/chrisimx/scanbridge/ScanSettings.kt @@ -96,7 +96,7 @@ fun ScanSettingsUI( val userUnitString = when (userUnitEnum) { ScanSettingsLengthUnit.INCH -> stringResource(R.string.inches) - ScanSettingsLengthUnit.MILLIMETER -> stringResource(R.string.mm) + ScanSettingsLengthUnit.MILLIMETER -> stringResource(R.string.millimeter_unit_abbreviation) } val scrollState = rememberScrollState() From f08179df3d2faa5f7ac1eaa06be6780022f870f2 Mon Sep 17 00:00:00 2001 From: Christian Nagel Date: Fri, 20 Feb 2026 23:34:46 +0100 Subject: [PATCH 10/16] Account for duplex setting in setInputSource of ScanSettings viewModel --- .../scanbridge/data/ui/ScanSettingsComposableViewModel.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/src/main/java/io/github/chrisimx/scanbridge/data/ui/ScanSettingsComposableViewModel.kt b/app/src/main/java/io/github/chrisimx/scanbridge/data/ui/ScanSettingsComposableViewModel.kt index 099063f..44cac86 100644 --- a/app/src/main/java/io/github/chrisimx/scanbridge/data/ui/ScanSettingsComposableViewModel.kt +++ b/app/src/main/java/io/github/chrisimx/scanbridge/data/ui/ScanSettingsComposableViewModel.kt @@ -282,7 +282,7 @@ class ScanSettingsComposableViewModel( fun setInputSource(inputSource: InputSource) { _uiState.update { val currentScanSettings = it.scanSettings - val inputSourceCaps = it.capabilities.getInputSourceCaps(inputSource) + val inputSourceCaps = it.capabilities.getInputSourceCaps(inputSource, currentScanSettings.duplex == true) val supportedResolutions = inputSourceCaps.settingProfiles[0].supportedResolutions.discreteResolutions From 96c68fe1142c381fa3c3ed6e740d8f895efa0587 Mon Sep 17 00:00:00 2001 From: Christian Nagel Date: Fri, 20 Feb 2026 23:43:48 +0100 Subject: [PATCH 11/16] Improve name for invalidResolutionSetting variable in ScanSettingsComposableViewModel --- .../scanbridge/data/ui/ScanSettingsComposableViewModel.kt | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/app/src/main/java/io/github/chrisimx/scanbridge/data/ui/ScanSettingsComposableViewModel.kt b/app/src/main/java/io/github/chrisimx/scanbridge/data/ui/ScanSettingsComposableViewModel.kt index 44cac86..7f4ed6a 100644 --- a/app/src/main/java/io/github/chrisimx/scanbridge/data/ui/ScanSettingsComposableViewModel.kt +++ b/app/src/main/java/io/github/chrisimx/scanbridge/data/ui/ScanSettingsComposableViewModel.kt @@ -289,10 +289,10 @@ class ScanSettingsComposableViewModel( val xRes = currentScanSettings.xResolution val yRes = currentScanSettings.yResolution - val validResolutionSetting = xRes != null && yRes != null && + val invalidResolutionSetting = xRes != null && yRes != null && !supportedResolutions.contains(DiscreteResolution(xRes, yRes)) - val replacementResolution = if (validResolutionSetting) { + val replacementResolution = if (invalidResolutionSetting) { val highestScanResolution = it.capabilities.getMaxResolution(inputSource) Pair(highestScanResolution.xResolution, highestScanResolution.yResolution) From 111fd4637ed815f84af0afea6b1af07597b5e1f8 Mon Sep 17 00:00:00 2001 From: Christian Nagel Date: Fri, 20 Feb 2026 23:44:53 +0100 Subject: [PATCH 12/16] Remove unnecessary Forest Companion property access --- .../chrisimx/scanbridge/services/AndroidLocaleProvider.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/src/main/java/io/github/chrisimx/scanbridge/services/AndroidLocaleProvider.kt b/app/src/main/java/io/github/chrisimx/scanbridge/services/AndroidLocaleProvider.kt index b168cb8..db456fe 100644 --- a/app/src/main/java/io/github/chrisimx/scanbridge/services/AndroidLocaleProvider.kt +++ b/app/src/main/java/io/github/chrisimx/scanbridge/services/AndroidLocaleProvider.kt @@ -14,7 +14,7 @@ class AndroidLocaleProvider : LocaleProvider { internal fun update() { val locale = Locale.getDefault() - Timber.Forest.d("Locale updated to $locale") + Timber.d("Locale updated to $locale") _locale.value = locale } } From a6f0d1b1d4c863b90b7d7dd90c21642169e4b623 Mon Sep 17 00:00:00 2001 From: Christian Nagel Date: Sat, 21 Feb 2026 00:11:59 +0100 Subject: [PATCH 13/16] Convert ScanSettingsComposableViewModel.kt to a state holder class --- .../scanbridge/ScanBridgeApplication.kt | 5 +- .../chrisimx/scanbridge/ScanSettings.kt | 53 +++++++++---------- ...t => ScanSettingsComposableStateHolder.kt} | 43 +++++++-------- .../scanbridge/data/ui/ScanningScreenData.kt | 4 +- .../data/ui/ScanningScreenViewModel.kt | 22 +++----- 5 files changed, 60 insertions(+), 67 deletions(-) rename app/src/main/java/io/github/chrisimx/scanbridge/data/ui/{ScanSettingsComposableViewModel.kt => ScanSettingsComposableStateHolder.kt} (93%) diff --git a/app/src/main/java/io/github/chrisimx/scanbridge/ScanBridgeApplication.kt b/app/src/main/java/io/github/chrisimx/scanbridge/ScanBridgeApplication.kt index 9b11be1..23fce7c 100644 --- a/app/src/main/java/io/github/chrisimx/scanbridge/ScanBridgeApplication.kt +++ b/app/src/main/java/io/github/chrisimx/scanbridge/ScanBridgeApplication.kt @@ -1,7 +1,7 @@ package io.github.chrisimx.scanbridge import android.app.Application -import io.github.chrisimx.scanbridge.data.ui.ScanSettingsComposableViewModel +import io.github.chrisimx.scanbridge.data.ui.ScanSettingsComposableStateHolder import io.github.chrisimx.scanbridge.data.ui.ScanningScreenViewModel import io.github.chrisimx.scanbridge.services.AndroidLocaleProvider import io.github.chrisimx.scanbridge.services.LocaleProvider @@ -9,13 +9,14 @@ import org.koin.android.ext.koin.androidContext import org.koin.core.context.startKoin import org.koin.dsl.bind import org.koin.dsl.module +import org.koin.plugin.module.dsl.factory import org.koin.plugin.module.dsl.single import org.koin.plugin.module.dsl.viewModel import timber.log.Timber val appModule = module { single() bind LocaleProvider::class - viewModel() + factory() viewModel() } diff --git a/app/src/main/java/io/github/chrisimx/scanbridge/ScanSettings.kt b/app/src/main/java/io/github/chrisimx/scanbridge/ScanSettings.kt index 819b44a..36b74e4 100644 --- a/app/src/main/java/io/github/chrisimx/scanbridge/ScanSettings.kt +++ b/app/src/main/java/io/github/chrisimx/scanbridge/ScanSettings.kt @@ -57,12 +57,11 @@ import io.github.chrisimx.esclkt.DiscreteResolution import io.github.chrisimx.esclkt.SupportedResolutions import io.github.chrisimx.esclkt.equalsLength import io.github.chrisimx.scanbridge.data.ui.NumberValidationResult -import io.github.chrisimx.scanbridge.data.ui.ScanSettingsComposableViewModel +import io.github.chrisimx.scanbridge.data.ui.ScanSettingsComposableStateHolder import io.github.chrisimx.scanbridge.data.ui.ScanSettingsLengthUnit import io.github.chrisimx.scanbridge.uicomponents.SizeBasedConditionalView import io.github.chrisimx.scanbridge.uicomponents.ValidatedDimensionsTextEdit import io.github.chrisimx.scanbridge.util.toReadableString -import org.koin.androidx.compose.koinViewModel import timber.log.Timber @OptIn( @@ -75,24 +74,24 @@ private val TAG = "ScanSettings" @Composable fun ScanSettingsUI( modifier: Modifier, - scanSettingsViewModel: ScanSettingsComposableViewModel = koinViewModel() + scanSettingsStateHolder: ScanSettingsComposableStateHolder ) { val context = LocalContext.current - val vmData by scanSettingsViewModel.uiState.collectAsState() + val vmData by scanSettingsStateHolder.uiState.collectAsState() - val currentResolution by scanSettingsViewModel.currentResolution.collectAsState() - val currentScanRegion by scanSettingsViewModel.currentScanRegion.collectAsState() + val currentResolution by scanSettingsStateHolder.currentResolution.collectAsState() + val currentScanRegion by scanSettingsStateHolder.currentScanRegion.collectAsState() - val duplexCurrentlyAvailable by scanSettingsViewModel.duplexCurrentlyAvailable.collectAsState() + val duplexCurrentlyAvailable by scanSettingsStateHolder.duplexCurrentlyAvailable.collectAsState() - val inputSourceOptions by scanSettingsViewModel.inputSourceOptions.collectAsState() - val supportedResolutions by scanSettingsViewModel.supportedScanResolutions.collectAsState() - val intentOptions by scanSettingsViewModel.intentOptions.collectAsState() + val inputSourceOptions by scanSettingsStateHolder.inputSourceOptions.collectAsState() + val supportedResolutions by scanSettingsStateHolder.supportedScanResolutions.collectAsState() + val intentOptions by scanSettingsStateHolder.intentOptions.collectAsState() - val widthValidationResult by scanSettingsViewModel.widthValidationResult.collectAsState(NumberValidationResult.NotANumber) - val heightValidationResult by scanSettingsViewModel.heightValidationResult.collectAsState(NumberValidationResult.NotANumber) + val widthValidationResult by scanSettingsStateHolder.widthValidationResult.collectAsState(NumberValidationResult.NotANumber) + val heightValidationResult by scanSettingsStateHolder.heightValidationResult.collectAsState(NumberValidationResult.NotANumber) - val userUnitEnum by scanSettingsViewModel.lengthUnit.collectAsState(ScanSettingsLengthUnit.MILLIMETER) + val userUnitEnum by scanSettingsStateHolder.lengthUnit.collectAsState(ScanSettingsLengthUnit.MILLIMETER) val userUnitString = when (userUnitEnum) { ScanSettingsLengthUnit.INCH -> stringResource(R.string.inches) @@ -122,7 +121,7 @@ fun ScanSettingsUI( index = index, count = inputSourceOptions.size ), - onClick = { scanSettingsViewModel.setInputSource(inputSource) }, + onClick = { scanSettingsStateHolder.setInputSource(inputSource) }, selected = vmData.scanSettings.inputSource == inputSource ) { Text(inputSource.toReadableString(context)) @@ -132,7 +131,7 @@ fun ScanSettingsUI( ToggleButton( enabled = duplexCurrentlyAvailable, checked = vmData.scanSettings.duplex == true, - onCheckedChange = { scanSettingsViewModel.setDuplex(it) } + onCheckedChange = { scanSettingsStateHolder.setDuplex(it) } ) { Text(stringResource(R.string.setting_duplex)) } } @@ -142,12 +141,12 @@ fun ScanSettingsUI( modifier = Modifier, largeView = { ResolutionSettingButtonRowVersion(supportedResolutions, currentResolution) { x, y -> - scanSettingsViewModel.setResolution(x, y) + scanSettingsStateHolder.setResolution(x, y) } }, smallView = { ResolutionSettingCardVersion(supportedResolutions, currentResolution) { x, y -> - scanSettingsViewModel.setResolution(x, y) + scanSettingsStateHolder.setResolution(x, y) } }, onViewChosen = { fitsRowVersion = it } @@ -174,7 +173,7 @@ fun ScanSettingsUI( val name = intentData.asString() InputChip( onClick = { - scanSettingsViewModel.setIntent(intentData) + scanSettingsStateHolder.setIntent(intentData) }, label = { Text(name) }, selected = vmData.scanSettings.intent == intentData @@ -182,7 +181,7 @@ fun ScanSettingsUI( } InputChip( onClick = { - scanSettingsViewModel.setIntent(null) + scanSettingsStateHolder.setIntent(null) }, label = { Text(stringResource(R.string.intent_none)) }, selected = vmData.scanSettings.intent == null @@ -209,8 +208,8 @@ fun ScanSettingsUI( vmData.paperFormats.forEach { paperFormat -> InputChip( onClick = { - scanSettingsViewModel.setCustomMenuEnabled(false) - scanSettingsViewModel.setRegionDimension( + scanSettingsStateHolder.setCustomMenuEnabled(false) + scanSettingsStateHolder.setRegionDimension( paperFormat.width, paperFormat.height ) @@ -224,8 +223,8 @@ fun ScanSettingsUI( } InputChip( onClick = { - scanSettingsViewModel.setCustomMenuEnabled(false) - scanSettingsViewModel.selectMaxRegion() + scanSettingsStateHolder.setCustomMenuEnabled(false) + scanSettingsStateHolder.selectMaxRegion() }, label = { Text(stringResource(R.string.maximum_size)) }, selected = @@ -233,7 +232,7 @@ fun ScanSettingsUI( ) InputChip( selected = vmData.customMenuEnabled, - onClick = { scanSettingsViewModel.setCustomMenuEnabled(true) }, + onClick = { scanSettingsStateHolder.setCustomMenuEnabled(true) }, label = { Text(stringResource(R.string.custom)) } ) } @@ -247,7 +246,7 @@ fun ScanSettingsUI( .padding(end = 10.dp), stringResource(R.string.width_in_unit, userUnitString), { newText: String -> - scanSettingsViewModel.setCustomWidthTextFieldContent( + scanSettingsStateHolder.setCustomWidthTextFieldContent( newText ) }, @@ -260,7 +259,7 @@ fun ScanSettingsUI( .weight(1f) .padding(start = 10.dp), stringResource(R.string.height_in_unit, userUnitString), - { scanSettingsViewModel.setCustomHeightTextFieldContent(it) }, + { scanSettingsStateHolder.setCustomHeightTextFieldContent(it) }, heightValidationResult ) } @@ -269,7 +268,7 @@ fun ScanSettingsUI( } Button( modifier = Modifier.padding(horizontal = 15.dp).testTag("copyesclkt"), - onClick = { scanSettingsViewModel.copySettingsToClipboard() } + onClick = { scanSettingsStateHolder.copySettingsToClipboard() } ) { Text( stringResource(R.string.copy_current_scanner_options_in_esclkt_format), diff --git a/app/src/main/java/io/github/chrisimx/scanbridge/data/ui/ScanSettingsComposableViewModel.kt b/app/src/main/java/io/github/chrisimx/scanbridge/data/ui/ScanSettingsComposableStateHolder.kt similarity index 93% rename from app/src/main/java/io/github/chrisimx/scanbridge/data/ui/ScanSettingsComposableViewModel.kt rename to app/src/main/java/io/github/chrisimx/scanbridge/data/ui/ScanSettingsComposableStateHolder.kt index 7f4ed6a..b24e4fe 100644 --- a/app/src/main/java/io/github/chrisimx/scanbridge/data/ui/ScanSettingsComposableViewModel.kt +++ b/app/src/main/java/io/github/chrisimx/scanbridge/data/ui/ScanSettingsComposableStateHolder.kt @@ -23,8 +23,6 @@ import android.app.Application import android.content.ClipData import android.content.ClipboardManager import android.content.Context.CLIPBOARD_SERVICE -import androidx.lifecycle.ViewModel -import androidx.lifecycle.viewModelScope import io.github.chrisimx.esclkt.DiscreteResolution import io.github.chrisimx.esclkt.InputSource import io.github.chrisimx.esclkt.InputSourceCaps @@ -44,6 +42,7 @@ import io.github.chrisimx.scanbridge.util.derived import io.github.chrisimx.scanbridge.util.getMaxResolution import io.github.chrisimx.scanbridge.util.toDoubleLocalized import java.util.* +import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow @@ -64,43 +63,45 @@ enum class ScanSettingsLengthUnit { MILLIMETER } -class ScanSettingsComposableViewModel( +class ScanSettingsComposableStateHolder( @InjectedParam private val initialScanSettingsData: ScanSettingsComposableData, @InjectedParam private val onSettingsChanged: (() -> Unit)? = null, + @InjectedParam + private val coroutineScope: CoroutineScope, private val localeProvider: LocaleProvider, private val context: Application -) : ViewModel() { +) { private val _uiState = MutableStateFlow(initialScanSettingsData) val uiState: StateFlow = _uiState.asStateFlow() - val inputSourceOptions: StateFlow> = _uiState.derived(viewModelScope) { + val inputSourceOptions: StateFlow> = _uiState.derived(coroutineScope) { it.capabilities.getInputSourceOptions() } - val duplexAdfSupported: StateFlow = _uiState.derived(viewModelScope) { + val duplexAdfSupported: StateFlow = _uiState.derived(coroutineScope) { it.capabilities.adf?.duplexCaps != null } val duplexCurrentlyAvailable: StateFlow = combine(duplexAdfSupported, _uiState) { duplexSupport, uiState -> duplexSupport && uiState.scanSettings.inputSource == InputSource.Feeder - }.stateIn(viewModelScope, SharingStarted.Lazily, false) + }.stateIn(coroutineScope, SharingStarted.Lazily, false) - private val selectedInputSourceCaps: StateFlow = _uiState.derived(viewModelScope) { + private val selectedInputSourceCaps: StateFlow = _uiState.derived(coroutineScope) { it.capabilities.getInputSourceCaps(it.scanSettings.inputSource, it.scanSettings.duplex ?: false) } - val intentOptions = selectedInputSourceCaps.derived(viewModelScope) { + val intentOptions = selectedInputSourceCaps.derived(coroutineScope) { it.supportedIntents } - val supportedScanResolutions = selectedInputSourceCaps.derived(viewModelScope) { + val supportedScanResolutions = selectedInputSourceCaps.derived(coroutineScope) { it.settingProfiles[0].supportedResolutions } - val currentResolution: StateFlow = uiState.derived(viewModelScope) { + val currentResolution: StateFlow = uiState.derived(coroutineScope) { val settings = it.scanSettings val x = settings.xResolution val y = settings.yResolution @@ -108,31 +109,31 @@ class ScanSettingsComposableViewModel( if (x != null && y != null) DiscreteResolution(x, y) else null } - val lengthUnit = localeProvider.locale.derived(viewModelScope) { + val lengthUnit = localeProvider.locale.derived(coroutineScope) { unitByLocale(it) } - val currentWidthText = _uiState.derived(viewModelScope) { + val currentWidthText = _uiState.derived(coroutineScope) { it.widthString } - val currentHeightText = _uiState.derived(viewModelScope) { + val currentHeightText = _uiState.derived(coroutineScope) { it.heightString } - val currentScanRegion = _uiState.derived(viewModelScope) { + val currentScanRegion = _uiState.derived(coroutineScope) { it.scanSettings.scanRegions?.regions?.firstOrNull() } val heightValidationResult = combine(currentHeightText, lengthUnit, selectedInputSourceCaps) { heightText, unit, inputSourceCaps -> return@combine validateCustomLengthInput(heightText, unit, inputSourceCaps.maxHeight, inputSourceCaps.minHeight) - } + }.stateIn(coroutineScope, SharingStarted.Lazily, NumberValidationResult.NotANumber) val widthValidationResult = combine(currentWidthText, lengthUnit, selectedInputSourceCaps) { widthText, unit, inputSourceCaps -> return@combine validateCustomLengthInput(widthText, unit, inputSourceCaps.maxWidth, inputSourceCaps.minWidth) - } + }.stateIn(coroutineScope, SharingStarted.Lazily, NumberValidationResult.NotANumber) private fun validateCustomLengthInput( lengthText: String, @@ -183,7 +184,7 @@ class ScanSettingsComposableViewModel( .map { it.scanSettings } .distinctUntilChanged() .onEach { onSettingsChanged?.invoke() } - .launchIn(viewModelScope) + .launchIn(coroutineScope) observeHeightValidation() observeWidthValidation() @@ -204,7 +205,7 @@ class ScanSettingsComposableViewModel( } ) } - }.launchIn(viewModelScope) + }.launchIn(coroutineScope) } private fun observeWidthValidation() { @@ -237,7 +238,7 @@ class ScanSettingsComposableViewModel( } } } - .launchIn(viewModelScope) + .launchIn(coroutineScope) } private fun observeHeightValidation() { @@ -270,7 +271,7 @@ class ScanSettingsComposableViewModel( } } } - .launchIn(viewModelScope) + .launchIn(coroutineScope) } fun setDuplex(duplex: Boolean) { diff --git a/app/src/main/java/io/github/chrisimx/scanbridge/data/ui/ScanningScreenData.kt b/app/src/main/java/io/github/chrisimx/scanbridge/data/ui/ScanningScreenData.kt index 6f8b588..5bfd4b8 100644 --- a/app/src/main/java/io/github/chrisimx/scanbridge/data/ui/ScanningScreenData.kt +++ b/app/src/main/java/io/github/chrisimx/scanbridge/data/ui/ScanningScreenData.kt @@ -57,7 +57,7 @@ data class ScanningScreenData( val confirmDialogShown: MutableState = mutableStateOf(false), val confirmPageDeleteDialogShown: MutableState = mutableStateOf(false), val error: MutableState = mutableStateOf(null), - val scanSettingsVM: MutableState = mutableStateOf(null), + val scanSettingsVM: MutableState = mutableStateOf(null), val capabilities: MutableState = mutableStateOf(null), val scanSettingsMenuOpen: MutableState = mutableStateOf(false), val scanJobRunning: MutableState = mutableStateOf(false), @@ -105,7 +105,7 @@ data class ImmutableScanningScreenData( private val confirmDialogShownState: State, private val confirmPageDeleteDialogShownState: State, private val errorState: State, - private val scanSettingsVMState: State, + private val scanSettingsVMState: State, private val capabilitiesState: State, private val scanSettingsMenuOpenState: State, private val showExportOptionsState: State, diff --git a/app/src/main/java/io/github/chrisimx/scanbridge/data/ui/ScanningScreenViewModel.kt b/app/src/main/java/io/github/chrisimx/scanbridge/data/ui/ScanningScreenViewModel.kt index 56ad4d0..78b14cb 100644 --- a/app/src/main/java/io/github/chrisimx/scanbridge/data/ui/ScanningScreenViewModel.kt +++ b/app/src/main/java/io/github/chrisimx/scanbridge/data/ui/ScanningScreenViewModel.kt @@ -66,7 +66,7 @@ import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import kotlinx.serialization.ExperimentalSerializationApi import org.koin.core.parameter.parametersOf -import org.koin.core.qualifier.named +import org.koin.java.KoinJavaComponent.get import org.koin.mp.KoinPlatform.getKoin import timber.log.Timber @@ -111,16 +111,6 @@ class ScanningScreenViewModel( val scanningScreenData: ImmutableScanningScreenData get() = _scanningScreenData.toImmutable() - private val childScope = getKoin().createScope( - "scanSettingsScope", - named("ScanSettingsScope") - ) - - override fun onCleared() { - childScope.close() - super.onCleared() - } - fun addTempFile(file: File) { _scanningScreenData.createdTempFiles.add(file) saveSessionFile() @@ -255,7 +245,7 @@ class ScanningScreenViewModel( if (storedSession != null) { scanningScreenData.currentScansState.addAll(storedSession.scannedPages) _scanningScreenData.createdTempFiles.addAll(storedSession.tmpFiles.map { File(it) }) - _scanningScreenData.scanSettingsVM.value = childScope.get { + _scanningScreenData.scanSettingsVM.value = getKoin().get{ parametersOf( ScanSettingsComposableData( storedSession.scanSettings ?: caps.calculateDefaultESCLScanSettingsState(), @@ -264,7 +254,8 @@ class ScanningScreenViewModel( { saveScanSettings() saveSessionFile() - } + }, + viewModelScope ) } } else { @@ -353,7 +344,7 @@ class ScanningScreenViewModel( caps.calculateDefaultESCLScanSettingsState() } - _scanningScreenData.scanSettingsVM.value = childScope.get { + _scanningScreenData.scanSettingsVM.value = getKoin().get { parametersOf( ScanSettingsComposableData( initialSettings, @@ -362,7 +353,8 @@ class ScanningScreenViewModel( { saveScanSettings() saveSessionFile() - } + }, + viewModelScope ) } val sessionFile = application.applicationInfo.dataDir + "/files/" + scanningScreenData.sessionID + ".session" From 7fdfb5caeec4dbf95ee0aeb2f215094f98fb6008 Mon Sep 17 00:00:00 2001 From: Christian Nagel Date: Sat, 21 Feb 2026 00:13:53 +0100 Subject: [PATCH 14/16] Apply format --- .../main/java/io/github/chrisimx/scanbridge/ScanSettings.kt | 5 +---- .../chrisimx/scanbridge/data/ui/ScanningScreenViewModel.kt | 2 +- 2 files changed, 2 insertions(+), 5 deletions(-) diff --git a/app/src/main/java/io/github/chrisimx/scanbridge/ScanSettings.kt b/app/src/main/java/io/github/chrisimx/scanbridge/ScanSettings.kt index 36b74e4..21b7f99 100644 --- a/app/src/main/java/io/github/chrisimx/scanbridge/ScanSettings.kt +++ b/app/src/main/java/io/github/chrisimx/scanbridge/ScanSettings.kt @@ -72,10 +72,7 @@ private val TAG = "ScanSettings" @OptIn(ExperimentalLayoutApi::class, ExperimentalMaterial3ExpressiveApi::class) @Composable -fun ScanSettingsUI( - modifier: Modifier, - scanSettingsStateHolder: ScanSettingsComposableStateHolder -) { +fun ScanSettingsUI(modifier: Modifier, scanSettingsStateHolder: ScanSettingsComposableStateHolder) { val context = LocalContext.current val vmData by scanSettingsStateHolder.uiState.collectAsState() diff --git a/app/src/main/java/io/github/chrisimx/scanbridge/data/ui/ScanningScreenViewModel.kt b/app/src/main/java/io/github/chrisimx/scanbridge/data/ui/ScanningScreenViewModel.kt index 78b14cb..ae18823 100644 --- a/app/src/main/java/io/github/chrisimx/scanbridge/data/ui/ScanningScreenViewModel.kt +++ b/app/src/main/java/io/github/chrisimx/scanbridge/data/ui/ScanningScreenViewModel.kt @@ -245,7 +245,7 @@ class ScanningScreenViewModel( if (storedSession != null) { scanningScreenData.currentScansState.addAll(storedSession.scannedPages) _scanningScreenData.createdTempFiles.addAll(storedSession.tmpFiles.map { File(it) }) - _scanningScreenData.scanSettingsVM.value = getKoin().get{ + _scanningScreenData.scanSettingsVM.value = getKoin().get { parametersOf( ScanSettingsComposableData( storedSession.scanSettings ?: caps.calculateDefaultESCLScanSettingsState(), From 02b47759d1785e0fb93093dc809a48058ed750b0 Mon Sep 17 00:00:00 2001 From: Christian Nagel Date: Sat, 21 Feb 2026 00:30:35 +0100 Subject: [PATCH 15/16] Use validated values to determine selected input source caps when reusing saved scan settings (wrong values used before) --- .../chrisimx/scanbridge/data/ui/ScanningScreenViewModel.kt | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/app/src/main/java/io/github/chrisimx/scanbridge/data/ui/ScanningScreenViewModel.kt b/app/src/main/java/io/github/chrisimx/scanbridge/data/ui/ScanningScreenViewModel.kt index ae18823..4797a40 100644 --- a/app/src/main/java/io/github/chrisimx/scanbridge/data/ui/ScanningScreenViewModel.kt +++ b/app/src/main/java/io/github/chrisimx/scanbridge/data/ui/ScanningScreenViewModel.kt @@ -287,8 +287,8 @@ class ScanningScreenViewModel( } val selectedInputSourceCaps = caps.getInputSourceCaps( - savedSettings.inputSource ?: InputSource.Platen, - savedSettings.duplex ?: false + validatedInputSource ?: caps.getInputSourceOptions().first(), + duplex ?: false ) val intent = if (!selectedInputSourceCaps.supportedIntents.contains(savedSettings.intent)) { From 618cc6c284e5a7167e0d4c08fdd6dc54aa01e56c Mon Sep 17 00:00:00 2001 From: Christian Nagel Date: Sat, 21 Feb 2026 00:33:38 +0100 Subject: [PATCH 16/16] Actual use pageNr in ScanningScreenViewModel.scrollToPage --- .../chrisimx/scanbridge/data/ui/ScanningScreenViewModel.kt | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/app/src/main/java/io/github/chrisimx/scanbridge/data/ui/ScanningScreenViewModel.kt b/app/src/main/java/io/github/chrisimx/scanbridge/data/ui/ScanningScreenViewModel.kt index 4797a40..7b0e799 100644 --- a/app/src/main/java/io/github/chrisimx/scanbridge/data/ui/ScanningScreenViewModel.kt +++ b/app/src/main/java/io/github/chrisimx/scanbridge/data/ui/ScanningScreenViewModel.kt @@ -148,7 +148,7 @@ class ScanningScreenViewModel( fun scrollToPage(pageNr: Int, scope: CoroutineScope) { scope.launch { _scanningScreenData.pagerState.animateScrollToPage( - scanningScreenData.currentScansState.size + pageNr ) } } @@ -451,7 +451,7 @@ class ScanningScreenViewModel( setScanJobCancelling(false) scrollToPage( scope = snackBarScope, - pageNr = scanningScreenData.currentScansState.size + 1 + pageNr = scanningScreenData.currentScansState.size ) if (abortIfCancelling()) return