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/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/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..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,11 +1,33 @@ package io.github.chrisimx.scanbridge import android.app.Application +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 +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 + factory() + 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 644d74f..21b7f99 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 @@ -45,6 +41,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 @@ -56,10 +53,12 @@ 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.scanbridge.data.ui.ScanSettingsComposableViewModel +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.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 @@ -73,10 +72,28 @@ private val TAG = "ScanSettings" @OptIn(ExperimentalLayoutApi::class, ExperimentalMaterial3ExpressiveApi::class) @Composable -fun ScanSettingsUI(modifier: Modifier, context: Context, scanSettingsViewModel: ScanSettingsComposableViewModel = viewModel()) { - val scanSettingsUIState = scanSettingsViewModel.scanSettingsComposableData +fun ScanSettingsUI(modifier: Modifier, scanSettingsStateHolder: ScanSettingsComposableStateHolder) { + val context = LocalContext.current + val vmData by scanSettingsStateHolder.uiState.collectAsState() - assert(scanSettingsUIState.inputSourceOptions.isNotEmpty()) // The settings are useless if this is the case + val currentResolution by scanSettingsStateHolder.currentResolution.collectAsState() + val currentScanRegion by scanSettingsStateHolder.currentScanRegion.collectAsState() + + val duplexCurrentlyAvailable by scanSettingsStateHolder.duplexCurrentlyAvailable.collectAsState() + + val inputSourceOptions by scanSettingsStateHolder.inputSourceOptions.collectAsState() + val supportedResolutions by scanSettingsStateHolder.supportedScanResolutions.collectAsState() + val intentOptions by scanSettingsStateHolder.intentOptions.collectAsState() + + val widthValidationResult by scanSettingsStateHolder.widthValidationResult.collectAsState(NumberValidationResult.NotANumber) + val heightValidationResult by scanSettingsStateHolder.heightValidationResult.collectAsState(NumberValidationResult.NotANumber) + + val userUnitEnum by scanSettingsStateHolder.lengthUnit.collectAsState(ScanSettingsLengthUnit.MILLIMETER) + + val userUnitString = when (userUnitEnum) { + ScanSettingsLengthUnit.INCH -> stringResource(R.string.inches) + ScanSettingsLengthUnit.MILLIMETER -> stringResource(R.string.millimeter_unit_abbreviation) + } val scrollState = rememberScrollState() @@ -95,25 +112,23 @@ 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 = { scanSettingsStateHolder.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, - onCheckedChange = { scanSettingsViewModel.setDuplex(it) } + enabled = duplexCurrentlyAvailable, + checked = vmData.scanSettings.duplex == true, + onCheckedChange = { scanSettingsStateHolder.setDuplex(it) } ) { Text(stringResource(R.string.setting_duplex)) } } @@ -122,10 +137,14 @@ fun ScanSettingsUI(modifier: Modifier, context: Context, scanSettingsViewModel: SizeBasedConditionalView( modifier = Modifier, largeView = { - ResolutionSettingButtonRowVersion(scanSettingsUIState, scanSettingsViewModel) + ResolutionSettingButtonRowVersion(supportedResolutions, currentResolution) { x, y -> + scanSettingsStateHolder.setResolution(x, y) + } }, smallView = { - ResolutionSettingCardVersion(scanSettingsUIState, scanSettingsViewModel) + ResolutionSettingCardVersion(supportedResolutions, currentResolution) { x, y -> + scanSettingsStateHolder.setResolution(x, y) + } }, onViewChosen = { fitsRowVersion = it } ) @@ -147,22 +166,22 @@ 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) + scanSettingsStateHolder.setIntent(intentData) }, label = { Text(name) }, - selected = scanSettingsUIState.scanSettingsState.intent == intentData + selected = vmData.scanSettings.intent == intentData ) } InputChip( onClick = { - scanSettingsViewModel.setIntent(null) + scanSettingsStateHolder.setIntent(null) }, label = { Text(stringResource(R.string.intent_none)) }, - selected = scanSettingsUIState.scanSettingsState.intent == null + selected = vmData.scanSettings.intent == null ) } } @@ -183,100 +202,70 @@ 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() + scanSettingsStateHolder.setCustomMenuEnabled(false) + scanSettingsStateHolder.setRegionDimension( + 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") + scanSettingsStateHolder.setCustomMenuEnabled(false) + scanSettingsStateHolder.selectMaxRegion() }, label = { Text(stringResource(R.string.maximum_size)) }, selected = - scanSettingsUIState.scanSettingsState.scanRegions?.width == "max" && !scanSettingsUIState.customMenuEnabled + vmData.maximumSize && !vmData.customMenuEnabled ) InputChip( - selected = scanSettingsUIState.customMenuEnabled, - onClick = { scanSettingsViewModel.setCustomMenuEnabled(true) }, + selected = vmData.customMenuEnabled, + onClick = { scanSettingsStateHolder.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.setWidthTextFieldContent( + scanSettingsStateHolder.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), - { scanSettingsViewModel.setHeightTextFieldContent(it) }, - { - scanSettingsViewModel.setRegionDimension( - scanSettingsUIState.widthTextFieldString, - it - ) - }, - min = scanSettingsUIState.selectedInputSourceCapabilities.minHeight.toMillimeters().value, - max = scanSettingsUIState.selectedInputSourceCapabilities.maxHeight.toMillimeters().value + stringResource(R.string.height_in_unit, userUnitString), + { scanSettingsStateHolder.setCustomHeightTextFieldContent(it) }, + 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 = { scanSettingsStateHolder.copySettingsToClipboard() } ) { Text( stringResource(R.string.copy_current_scanner_options_in_esclkt_format), @@ -289,27 +278,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}") @@ -324,8 +309,9 @@ private fun ResolutionSettingButtonRowVersion( @Composable private fun ResolutionSettingCardVersion( - scanSettingsUIState: ImmutableScanSettingsComposableData, - scanSettingsViewModel: ScanSettingsComposableViewModel + supportedResolutions: SupportedResolutions, + currentResolution: DiscreteResolution?, + setSelectedResolution: (UInt, UInt) -> Unit ) { OutlinedCard( modifier = Modifier @@ -344,7 +330,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 { @@ -352,15 +338,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/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..a3eeb60 --- /dev/null +++ b/app/src/main/java/io/github/chrisimx/scanbridge/data/model/LegacyESCLScanSettings.kt @@ -0,0 +1,115 @@ +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..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 @@ -19,122 +19,26 @@ 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 NumberValidationResult { + data class Success(val value: Double) : NumberValidationResult() + data class OutOfRange(val min: Double, val max: Double) : NumberValidationResult() + data object NotANumber : NumberValidationResult() } @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 customMenuEnabled: Boolean, - val selectedInputSourceCapabilities: InputSourceCaps, - val intentOptions: List, - val supportedScanResolutions: List + val paperFormats: List = loadDefaultFormats(), + 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/ScanSettingsComposableStateHolder.kt b/app/src/main/java/io/github/chrisimx/scanbridge/data/ui/ScanSettingsComposableStateHolder.kt new file mode 100644 index 0000000..b24e4fe --- /dev/null +++ b/app/src/main/java/io/github/chrisimx/scanbridge/data/ui/ScanSettingsComposableStateHolder.kt @@ -0,0 +1,408 @@ +/* + * Copyright (C) 2024-2025 Christian Nagel and contributors + * + * This file is part of ScanBridge. + * + * ScanBridge is free software: you can redistribute it and/or modify it under the terms of + * the GNU General Public License as published by the Free Software Foundation, either + * version 3 of the License, or (at your option) any later version. + * + * ScanBridge is distributed in the hope that it will be useful, but WITHOUT ANY + * WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS + * FOR A PARTICULAR PURPOSE. See the GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License along with eSCLKt. + * If not, see . + * + * SPDX-License-Identifier: GPL-3.0-or-later + */ + +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 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.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.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.CoroutineScope +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 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 +) { + + private val _uiState = MutableStateFlow(initialScanSettingsData) + val uiState: StateFlow = _uiState.asStateFlow() + + val inputSourceOptions: StateFlow> = _uiState.derived(coroutineScope) { + it.capabilities.getInputSourceOptions() + } + + 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(coroutineScope, SharingStarted.Lazily, false) + + private val selectedInputSourceCaps: StateFlow = _uiState.derived(coroutineScope) { + it.capabilities.getInputSourceCaps(it.scanSettings.inputSource, it.scanSettings.duplex ?: false) + } + + val intentOptions = selectedInputSourceCaps.derived(coroutineScope) { + it.supportedIntents + } + + val supportedScanResolutions = selectedInputSourceCaps.derived(coroutineScope) { + it.settingProfiles[0].supportedResolutions + } + + val currentResolution: StateFlow = uiState.derived(coroutineScope) { + 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(coroutineScope) { + unitByLocale(it) + } + + val currentWidthText = _uiState.derived(coroutineScope) { + it.widthString + } + + val currentHeightText = _uiState.derived(coroutineScope) { + it.heightString + } + + 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, + unit: ScanSettingsLengthUnit, + max: ThreeHundredthsOfInch, + min: ThreeHundredthsOfInch + ): NumberValidationResult { + val parsedLength = runCatching { + lengthText.toDoubleLocalized() + }.getOrNull() + + if (parsedLength == null) { + return NumberValidationResult.NotANumber + } + + val lengthInUnit = when (unit) { + ScanSettingsLengthUnit.INCH -> parsedLength.inches() + ScanSettingsLengthUnit.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) { + ScanSettingsLengthUnit.INCH -> length.toInches().value + ScanSettingsLengthUnit.MILLIMETER -> length.toMillimeters().value + } + + 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(coroutineScope) + + 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(coroutineScope) + } + + 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(coroutineScope) + } + + 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(coroutineScope) + } + + fun setDuplex(duplex: Boolean) { + _uiState.updateScanSettings { + copy(duplex = duplex) + } + } + + fun setInputSource(inputSource: InputSource) { + _uiState.update { + val currentScanSettings = it.scanSettings + val inputSourceCaps = it.capabilities.getInputSourceCaps(inputSource, currentScanSettings.duplex == true) + + val supportedResolutions = inputSourceCaps.settingProfiles[0].supportedResolutions.discreteResolutions + + val xRes = currentScanSettings.xResolution + val yRes = currentScanSettings.yResolution + + val invalidResolutionSetting = xRes != null && yRes != null && + !supportedResolutions.contains(DiscreteResolution(xRes, yRes)) + + val replacementResolution = if (invalidResolutionSetting) { + 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 + } + + it.copy( + scanSettings = it.scanSettings.copy( + inputSource = inputSource, + xResolution = replacementResolution.first, + yResolution = replacementResolution.second, + intent = replacementIntent + ) + ) + } + } + + fun setResolution(xResolution: UInt, yResolution: UInt) { + _uiState.updateScanSettings { + copy( + xResolution = xResolution, + yResolution = yResolution + ) + } + } + + fun setIntent(intent: ScanIntentEnumOrRaw?) { + _uiState.updateScanSettings { + copy(intent = intent) + } + } + + fun setCustomMenuEnabled(enabled: Boolean) { + _uiState.update { + it.copy( + maximumSize = false, + customMenuEnabled = enabled + ) + } + } + + fun setCustomWidthTextFieldContent(width: String) { + check(_uiState.value.customMenuEnabled) + _uiState.update { + it.copy( + maximumSize = false, + widthString = width + ) + } + } + + fun setCustomHeightTextFieldContent(height: String) { + check(_uiState.value.customMenuEnabled) + _uiState.update { + it.copy( + maximumSize = false, + heightString = height + ) + } + } + + private fun unitByLocale(locale: Locale = Locale.getDefault()): ScanSettingsLengthUnit = + if (locale.country in setOf("US", "LR", "MM")) { + ScanSettingsLengthUnit.INCH + } else { + ScanSettingsLengthUnit.MILLIMETER + } + + fun selectMaxRegion() { + _uiState.update { + it.copy(maximumSize = true) + } + } + + fun setRegionDimension(newWidth: LengthUnit, newHeight: LengthUnit) { + _uiState.update { + val scanSettings = it.scanSettings.copy( + scanRegions = scanRegion { + 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/ScanSettingsComposableViewModel.kt b/app/src/main/java/io/github/chrisimx/scanbridge/data/ui/ScanSettingsComposableViewModel.kt deleted file mode 100644 index 285afed..0000000 --- a/app/src/main/java/io/github/chrisimx/scanbridge/data/ui/ScanSettingsComposableViewModel.kt +++ /dev/null @@ -1,124 +0,0 @@ -/* - * Copyright (C) 2024-2025 Christian Nagel and contributors - * - * This file is part of ScanBridge. - * - * ScanBridge is free software: you can redistribute it and/or modify it under the terms of - * the GNU General Public License as published by the Free Software Foundation, either - * version 3 of the License, or (at your option) any later version. - * - * ScanBridge is distributed in the hope that it will be useful, but WITHOUT ANY - * WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS - * FOR A PARTICULAR PURPOSE. See the GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License along with eSCLKt. - * If not, see . - * - * SPDX-License-Identifier: GPL-3.0-or-later - */ - -package io.github.chrisimx.scanbridge.data.ui - -import androidx.compose.runtime.mutableStateOf -import androidx.lifecycle.ViewModel -import io.github.chrisimx.esclkt.DiscreteResolution -import io.github.chrisimx.esclkt.InputSource -import io.github.chrisimx.esclkt.ScanIntentEnumOrRaw -import io.github.chrisimx.scanbridge.data.model.MutableScanRegionState - -class ScanSettingsComposableViewModel( - private val _scanSettingsComposableData: ScanSettingsComposableData, - private val onSettingsChanged: (() -> Unit)? = null -) : ViewModel() { - - val scanSettingsComposableData: ImmutableScanSettingsComposableData - get() = _scanSettingsComposableData.toImmutable() - - fun getMutableScanSettingsComposableData(): ScanSettingsComposableData = _scanSettingsComposableData - - fun setDuplex(duplex: Boolean) { - _scanSettingsComposableData.scanSettingsState.duplex = duplex - onSettingsChanged?.invoke() - } - - 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 - } - setResolution(highestScanResolution.xResolution, highestScanResolution.yResolution) - } - - val intentSupported = scanSettingsState.intent?.let { _scanSettingsComposableData.intentOptions.contains(it) } - if (intentSupported == false) { - setIntent(null) - } - } - - fun setResolution(xResolution: UInt, yResolution: UInt) { - _scanSettingsComposableData.scanSettingsState.xResolution = xResolution - _scanSettingsComposableData.scanSettingsState.yResolution = yResolution - onSettingsChanged?.invoke() - } - - fun setIntent(intent: ScanIntentEnumOrRaw?) { - _scanSettingsComposableData.scanSettingsState.intent = intent - onSettingsChanged?.invoke() - } - - fun setCustomMenuEnabled(enabled: Boolean) { - _scanSettingsComposableData.customMenuEnabled = enabled - } - - fun setWidthTextFieldContent(width: String) { - _scanSettingsComposableData.widthTextFieldString = width - } - - fun setHeightTextFieldContent(width: String) { - _scanSettingsComposableData.heightTextFieldString = width - } - - 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 - } - _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) - ) - 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/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 0b25f67..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 @@ -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.java.KoinJavaComponent.get +import org.koin.mp.KoinPlatform.getKoin import timber.log.Timber class ScanningScreenViewModel( @@ -144,7 +148,7 @@ class ScanningScreenViewModel( fun scrollToPage(pageNr: Int, scope: CoroutineScope) { scope.launch { _scanningScreenData.pagerState.animateScrollToPage( - scanningScreenData.currentScansState.size + pageNr ) } } @@ -225,7 +229,7 @@ class ScanningScreenViewModel( fun setScannerCapabilities(caps: ScannerCapabilities) { _scanningScreenData.capabilities.value = caps - val storedSessionResult = loadSessionFile() + val storedSessionResult = loadSessionFile(caps) storedSessionResult.onFailure { setError( @@ -241,51 +245,61 @@ 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 = getKoin().get { + parametersOf( + ScanSettingsComposableData( + storedSession.scanSettings ?: caps.calculateDefaultESCLScanSettingsState(), + caps + ), + { + saveScanSettings() + saveSessionFile() + }, + viewModelScope + ) + } } 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 + validatedInputSource ?: caps.getInputSourceOptions().first(), + 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 +307,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 +344,19 @@ class ScanningScreenViewModel( caps.calculateDefaultESCLScanSettingsState() } - _scanningScreenData.scanSettingsVM.value = ScanSettingsComposableViewModel( - ScanSettingsComposableData( - initialSettings, - caps - ), - onSettingsChanged = { - saveScanSettings() - saveSessionFile() - } - ) + _scanningScreenData.scanSettingsVM.value = getKoin().get { + parametersOf( + ScanSettingsComposableData( + initialSettings, + caps + ), + { + saveScanSettings() + saveSessionFile() + }, + viewModelScope + ) + } val sessionFile = application.applicationInfo.dataDir + "/files/" + scanningScreenData.sessionID + ".session" addTempFile(File(sessionFile)) saveSessionFile() @@ -344,14 +377,15 @@ 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) } @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 || @@ -377,7 +411,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 +442,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 71% 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..db456fe 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,16 +1,16 @@ -package io.github.chrisimx.scanbridge.util +package io.github.chrisimx.scanbridge.services import android.annotation.SuppressLint -import java.util.* +import java.util.Locale import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow import timber.log.Timber -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() 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..8aef6fd --- /dev/null +++ b/app/src/main/java/io/github/chrisimx/scanbridge/services/LocaleProvider.kt @@ -0,0 +1,8 @@ +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/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/uicomponents/ValidatedTextField.kt b/app/src/main/java/io/github/chrisimx/scanbridge/uicomponents/ValidatedTextField.kt index e78197d..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,83 +20,47 @@ 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.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) +import io.github.chrisimx.scanbridge.data.ui.NumberValidationResult +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/java/io/github/chrisimx/scanbridge/util/ESCLKtExtensions.kt b/app/src/main/java/io/github/chrisimx/scanbridge/util/ESCLKtExtensions.kt index aee1ed9..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 @@ -21,17 +21,17 @@ package io.github.chrisimx.scanbridge.util import android.content.Context 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,23 +46,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) InputSource.Feeder -> context.getString(R.string.adf) @@ -89,25 +72,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..e7f86ae --- /dev/null +++ b/app/src/main/java/io/github/chrisimx/scanbridge/util/StateFlowExtensions.kt @@ -0,0 +1,14 @@ +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) + ) diff --git a/app/src/main/res/values-de/strings.xml b/app/src/main/res/values-de/strings.xml index dafb77c..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 b543729..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 diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 01fbc5a..3a367ca 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. + mm + 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