diff --git a/androidApp/src/main/java/io/github/chrisimx/scanbridge/AppSettingsScreen.kt b/androidApp/src/main/java/io/github/chrisimx/scanbridge/AppSettingsScreen.kt index 4bc14d22..389f869c 100644 --- a/androidApp/src/main/java/io/github/chrisimx/scanbridge/AppSettingsScreen.kt +++ b/androidApp/src/main/java/io/github/chrisimx/scanbridge/AppSettingsScreen.kt @@ -30,12 +30,16 @@ import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.FlowRow import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.heightIn import androidx.compose.foundation.layout.padding import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.selection.toggleable import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Edit import androidx.compose.material3.Button import androidx.compose.material3.ButtonDefaults import androidx.compose.material3.Checkbox @@ -43,6 +47,7 @@ import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.ModalBottomSheet import androidx.compose.material3.OutlinedButton import androidx.compose.material3.OutlinedIconButton import androidx.compose.material3.Text @@ -57,6 +62,7 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalUriHandler +import androidx.compose.ui.platform.LocalWindowInfo import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.semantics.Role @@ -74,13 +80,23 @@ import io.github.chrisimx.scanbridge.uicomponents.settings.MoreInformationButton import io.github.chrisimx.scanbridge.uicomponents.settings.UIntSetting import io.github.chrisimx.scanbridge.uicomponents.settings.VersionComposable import java.io.File +import org.jetbrains.compose.resources.StringResource +import org.jetbrains.compose.resources.stringResource import org.koin.compose.koinInject +import scanbridge.composeui.generated.resources.Res +import scanbridge.composeui.generated.resources.debug_log_explanation +import scanbridge.composeui.generated.resources.disable_cert_checks_desc +import scanbridge.composeui.generated.resources.initial_scan_settings +import scanbridge.composeui.generated.resources.pdf_export_setting_info +import scanbridge.composeui.generated.resources.preferred_initial_scan_settings_setting_desc +import scanbridge.composeui.generated.resources.remember_scan_settings_desc +import scanbridge.composeui.generated.resources.timeout_info @Composable -fun DisableCertChecksSetting(onInformationRequested: (Int) -> Unit, checked: Boolean, setChecked: (Boolean) -> Unit) { +fun DisableCertChecksSetting(onInformationRequested: (StringResource) -> Unit, checked: Boolean, setChecked: (Boolean) -> Unit) { CheckboxSetting( stringResource(R.string.disable_cert_checks), - R.string.disable_cert_checks_desc, + Res.string.disable_cert_checks_desc, checked, setChecked ) { @@ -105,7 +121,7 @@ fun exportDebugLog(context: Context, debugLogService: DebugLogService, saveDebug } @Composable -fun DebugOptions(debugLog: Boolean, onInformationRequested: (Int) -> Unit, setWriteDebugLog: (Boolean) -> Unit) { +fun DebugOptions(debugLog: Boolean, onInformationRequested: (StringResource) -> Unit, setWriteDebugLog: (Boolean) -> Unit) { val context = LocalContext.current val debugLogService: DebugLogService = koinInject() @@ -165,7 +181,7 @@ fun DebugOptions(debugLog: Boolean, onInformationRequested: (Int) -> Unit, setWr } ) { MoreInformationButton { - onInformationRequested(R.string.debug_log_explanation) + onInformationRequested(Res.string.debug_log_explanation) } } } @@ -182,15 +198,67 @@ fun DebugOptions(debugLog: Boolean, onInformationRequested: (Int) -> Unit, setWr } } +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun InitialScanSettingsAppSetting( + preferredInitialScanSettingsPanelVisible: Boolean, + setMenuVis: (Boolean) -> Unit, + scanSettingsUIStateHolder: ScanSettingsComposableStateHolder, + setHelpText: (StringResource) -> Unit +) { + Row( + modifier = Modifier + .padding(vertical = 10.dp) + .fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically + ) { + OutlinedButton( + modifier = Modifier + .weight(1f, fill = false) + .padding(horizontal = 10.dp), + onClick = { + setMenuVis(true) + } + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { + Icon( + imageVector = Icons.Default.Edit, + contentDescription = "Edit" + ) + Text( + text = stringResource(Res.string.initial_scan_settings) + ) + } + } + + MoreInformationButton { + setHelpText(Res.string.preferred_initial_scan_settings_setting_desc) + } + } + + if (preferredInitialScanSettingsPanelVisible) { + val screenHeight = LocalWindowInfo.current.containerDpSize.height + ModalBottomSheet({ setMenuVis(false) }) { + ScanSettingsUI( + Modifier.heightIn(max = screenHeight * 0.8f), + scanSettingsUIStateHolder + ) + } + } +} + @ExperimentalMaterial3Api @Composable fun AppSettingsScreen(innerPadding: PaddingValues) { val vm = koinInject() - var information: Int? by remember { + var information: StringResource? by remember { mutableStateOf(null) } - val setInformationRequested = { it: Int -> information = it } + val setInformationRequested = { it: StringResource -> information = it } val scrollState = rememberScrollState() @@ -198,6 +266,8 @@ fun AppSettingsScreen(innerPadding: PaddingValues) { val rememberScanSettings by vm.rememberScanSettings.collectAsState() val writeDebugLogs by vm.writeDebugLogs.collectAsState() + val preferredInitialScanSettingsPanelVisible by vm.isPreferredInitialScanSettingsMenuVisible.collectAsState() + val defaultPdfExportChunkSize = vm.getDefaultPdfExportChunkSize() val defaultScanningResponseTimeout = vm.getDefaultScanningResponseTimeout() @@ -264,7 +334,7 @@ fun AppSettingsScreen(innerPadding: PaddingValues) { CheckboxSetting( stringResource(R.string.remember_scan_settings), - R.string.remember_scan_settings_desc, + Res.string.remember_scan_settings_desc, rememberScanSettings, vm::setRememberScanSettings ) { @@ -276,7 +346,7 @@ fun AppSettingsScreen(innerPadding: PaddingValues) { { vm.getScanningResponseTimeout() }, defaultScanningResponseTimeout, stringResource(R.string.timeout), - R.string.timeout_info, + Res.string.timeout_info, setInformationRequested, vm::setScanningResponseTimeout ) @@ -286,12 +356,20 @@ fun AppSettingsScreen(innerPadding: PaddingValues) { { vm.getPdfExportChunkSize() }, defaultPdfExportChunkSize, stringResource(R.string.pdf_export_max_pages_per_pdf), - R.string.pdf_export_setting_info, + Res.string.pdf_export_setting_info, setInformationRequested, vm::setPdfExportChunkSize, min = 1u, max = UInt.MAX_VALUE ) + + // Preferred initial scan settings + InitialScanSettingsAppSetting( + preferredInitialScanSettingsPanelVisible, + vm::setPreferredInitialScanSettingsMenuVisibility, + vm.prefInitialScanSettingsUIStateHolder, + setInformationRequested + ) } TitledCard( @@ -308,7 +386,7 @@ fun AppSettingsScreen(innerPadding: PaddingValues) { val currentInfo = information if (currentInfo != null) { SimpleTextDialog( - stringResource(currentInfo), + org.jetbrains.compose.resources.stringResource(currentInfo), { information = null } ) } diff --git a/androidApp/src/main/java/io/github/chrisimx/scanbridge/DefaultInitialScanSettingsProvider.kt b/androidApp/src/main/java/io/github/chrisimx/scanbridge/DefaultInitialScanSettingsProvider.kt deleted file mode 100644 index 552b7651..00000000 --- a/androidApp/src/main/java/io/github/chrisimx/scanbridge/DefaultInitialScanSettingsProvider.kt +++ /dev/null @@ -1,14 +0,0 @@ -package io.github.chrisimx.scanbridge - -import io.github.chrisimx.anyscan.CommonScanSettingsEditor -import io.github.chrisimx.anyscan.CommonScannerCapabilities -import io.github.chrisimx.anyscan.FileFormat -import io.github.chrisimx.enumorrawcodegen.AnyScanEnumOrRaw -import io.github.chrisimx.scanbridge.ports.InitialScanSettingsProvider - -class DefaultInitialScanSettingsProvider : InitialScanSettingsProvider { - override fun applyDefaults(editor: CommonScanSettingsEditor, capabilities: CommonScannerCapabilities) { - editor.setInputSource(capabilities.inputSources.first().inputSourceType) - editor.setFormat(AnyScanEnumOrRaw.Known(FileFormat.JPEG)) - } -} diff --git a/androidApp/src/main/java/io/github/chrisimx/scanbridge/ScanBridgeApplication.kt b/androidApp/src/main/java/io/github/chrisimx/scanbridge/ScanBridgeApplication.kt index 05d8b5fd..680d8d22 100644 --- a/androidApp/src/main/java/io/github/chrisimx/scanbridge/ScanBridgeApplication.kt +++ b/androidApp/src/main/java/io/github/chrisimx/scanbridge/ScanBridgeApplication.kt @@ -8,13 +8,13 @@ import android.app.Application import android.content.Context import coil3.ImageLoader import coil3.network.ktor3.KtorNetworkFetcherFactory +import io.github.chrisimx.localization.JvmNumberFormatter import io.github.chrisimx.scanbridge.adapters.KoinBasedScanningProtocolManager import io.github.chrisimx.scanbridge.adapters.RoomBackedCustomScannerRepository import io.github.chrisimx.scanbridge.appsettings.AppSettingsRepository import io.github.chrisimx.scanbridge.appsettings.AppSettingsViewModel import io.github.chrisimx.scanbridge.appsettings.RoomAppSettingsRepository import io.github.chrisimx.scanbridge.buildinfo.BuildInfoProvider -import io.github.chrisimx.scanbridge.data.ui.ScanSettingsComposableStateHolder import io.github.chrisimx.scanbridge.data.ui.ScanningScreenViewModel import io.github.chrisimx.scanbridge.db.DefaultScanBridgeDbFactory import io.github.chrisimx.scanbridge.db.ScanBridgeDb @@ -22,6 +22,9 @@ import io.github.chrisimx.scanbridge.db.ScanBridgeDbBuilderFactory import io.github.chrisimx.scanbridge.db.ScanBridgeDbFactory import io.github.chrisimx.scanbridge.db.migrations.ROOM_MIGRATIONS import io.github.chrisimx.scanbridge.infrastructure.KmLogScanBridgeLoggerFactory +import io.github.chrisimx.scanbridge.initialscansettings.DefaultInitialScanSettingsProvider +import io.github.chrisimx.scanbridge.initialscansettings.InitialScanSettingsProvider +import io.github.chrisimx.scanbridge.localization.NumberFormatter import io.github.chrisimx.scanbridge.migrations.MigrationExecutor import io.github.chrisimx.scanbridge.migrations.RoomBackedMigrationExecutor import io.github.chrisimx.scanbridge.migrations.ds2room.DATASTORE_TO_ROOM_MIGRATION_DATA_SOURCES @@ -29,7 +32,6 @@ import io.github.chrisimx.scanbridge.migrations.migrationsModule import io.github.chrisimx.scanbridge.model.HttpClientConfig import io.github.chrisimx.scanbridge.ports.CustomScannerRepository import io.github.chrisimx.scanbridge.ports.HttpClientFactory -import io.github.chrisimx.scanbridge.ports.InitialScanSettingsProvider import io.github.chrisimx.scanbridge.ports.LocaleProvider import io.github.chrisimx.scanbridge.ports.MdnsDiscoverService import io.github.chrisimx.scanbridge.ports.ScanBridgeLoggerFactory @@ -133,6 +135,8 @@ val appModule = module { viewModel() + single() bind NumberFormatter::class + includes(SCAN_PROTOCOLS, ROOM_MIGRATIONS, DATASTORE_TO_ROOM_MIGRATION_DATA_SOURCES) } diff --git a/androidApp/src/main/java/io/github/chrisimx/scanbridge/ScanSettings.kt b/androidApp/src/main/java/io/github/chrisimx/scanbridge/ScanSettings.kt index f8aad017..5e54d145 100644 --- a/androidApp/src/main/java/io/github/chrisimx/scanbridge/ScanSettings.kt +++ b/androidApp/src/main/java/io/github/chrisimx/scanbridge/ScanSettings.kt @@ -50,26 +50,22 @@ import io.github.chrisimx.anyscan.CommonScanSettings import io.github.chrisimx.anyscan.LengthUnit import io.github.chrisimx.anyscan.ScanSettingParam import io.github.chrisimx.anyscan.ScannerConcept -import io.github.chrisimx.scanbridge.data.ui.ScanSettingsComposableStateHolder -import io.github.chrisimx.scanbridge.data.ui.ScanSettingsLengthUnit +import io.github.chrisimx.scanbridge.model.UIInputSourceType import io.github.chrisimx.scanbridge.uicomponents.SelectionButtonRow import io.github.chrisimx.scanbridge.uicomponents.SelectionCard import io.github.chrisimx.scanbridge.uicomponents.SizeBasedConditionalView import io.github.chrisimx.scanbridge.uicomponents.ValidatedDimensionsTextEdit -import io.github.chrisimx.scanbridge.util.UIInputSourceType import io.github.chrisimx.scanbridge.util.toLocalizedName import io.github.chrisimx.scanbridge.util.toReadableString @OptIn(ExperimentalLayoutApi::class, ExperimentalMaterial3ExpressiveApi::class) @Composable fun ScanSettingsUI(modifier: Modifier, scanSettingsStateHolder: ScanSettingsComposableStateHolder) { - val vmData by scanSettingsStateHolder.uiState.collectAsState() - val duplexCurrentlyAvailable by scanSettingsStateHolder.duplexSettingAvailable.collectAsState() val inputSourceOptions by scanSettingsStateHolder.inputSourceOptions.collectAsState() - val userUnitEnum by scanSettingsStateHolder.lengthUnit.collectAsState(ScanSettingsLengthUnit.MILLIMETER) + val userUnitEnum by scanSettingsStateHolder.userLengthUnit.collectAsState(ScanSettingsLengthUnit.MILLIMETER) val userUnitString = when (userUnitEnum) { ScanSettingsLengthUnit.INCH -> stringResource(R.string.inches) @@ -146,6 +142,8 @@ private fun RegionParameterDisplay( val widthValidationResult by scanSettingsStateHolder.validationResultWidth.collectAsState() val heightValidationResult by scanSettingsStateHolder.validationResultHeight.collectAsState() + val currentVmData = vmData ?: return + OutlinedCard( modifier = Modifier .fillMaxWidth() @@ -168,7 +166,7 @@ private fun RegionParameterDisplay( scanSettingsStateHolder.setFormat(paperFormat) }, label = { Text(paperFormat.name) }, - selected = !vmData.customMenuEnabled && !vmData.maximumSize && + selected = !currentVmData.customMenuEnabled && !currentVmData.maximumSize && currentScanRegion?.value?.width?.equalsLength(paperFormat.width) == true && currentScanRegion?.value?.height?.equalsLength(paperFormat.height) == true ) @@ -179,18 +177,18 @@ private fun RegionParameterDisplay( }, label = { Text(stringResource(R.string.maximum_size)) }, selected = - vmData.maximumSize && !vmData.customMenuEnabled + currentVmData.maximumSize && !currentVmData.customMenuEnabled ) InputChip( - selected = vmData.customMenuEnabled, + selected = currentVmData.customMenuEnabled, onClick = { scanSettingsStateHolder.setCustomMenuEnabled(true) }, label = { Text(stringResource(R.string.custom)) } ) } - AnimatedVisibility(vmData.customMenuEnabled) { + AnimatedVisibility(currentVmData.customMenuEnabled) { Row(horizontalArrangement = Arrangement.SpaceEvenly) { ValidatedDimensionsTextEdit( - vmData.widthString, + currentVmData.widthString, modifier = Modifier .weight(1f) .padding(end = 10.dp), @@ -203,7 +201,7 @@ private fun RegionParameterDisplay( widthValidationResult ) ValidatedDimensionsTextEdit( - vmData.heightString, + currentVmData.heightString, modifier = Modifier .weight(1f) .padding(start = 10.dp), diff --git a/androidApp/src/main/java/io/github/chrisimx/scanbridge/ScanningScreen.kt b/androidApp/src/main/java/io/github/chrisimx/scanbridge/ScanningScreen.kt index 5d3f9789..da283dbe 100644 --- a/androidApp/src/main/java/io/github/chrisimx/scanbridge/ScanningScreen.kt +++ b/androidApp/src/main/java/io/github/chrisimx/scanbridge/ScanningScreen.kt @@ -95,6 +95,7 @@ import coil3.compose.AsyncImage import io.github.chrisimx.scanbridge.data.ui.ScanningScreenViewModel import io.github.chrisimx.scanbridge.db.entities.ScannedPage import io.github.chrisimx.scanbridge.model.ScannerHandle +import io.github.chrisimx.scanbridge.model.toUIInputSourceType import io.github.chrisimx.scanbridge.services.ScanJobEvent import io.github.chrisimx.scanbridge.uicomponents.ExportSettingsPopup import io.github.chrisimx.scanbridge.uicomponents.FullScreenError @@ -108,7 +109,6 @@ import io.github.chrisimx.scanbridge.util.clearAndNavigateTo import io.github.chrisimx.scanbridge.util.snackBarError import io.github.chrisimx.scanbridge.util.snackbarErrorRetrievingPage import io.github.chrisimx.scanbridge.util.toReadableString -import io.github.chrisimx.scanbridge.util.toUIInputSourceType import java.io.File import kotlin.uuid.Uuid import kotlinx.coroutines.CoroutineScope diff --git a/androidApp/src/main/java/io/github/chrisimx/scanbridge/data/model/LegacyESCLScanSettings.kt b/androidApp/src/main/java/io/github/chrisimx/scanbridge/data/model/LegacyESCLScanSettings.kt deleted file mode 100644 index f16d9f7b..00000000 --- a/androidApp/src/main/java/io/github/chrisimx/scanbridge/data/model/LegacyESCLScanSettings.kt +++ /dev/null @@ -1,115 +0,0 @@ -package io.github.chrisimx.scanbridge.data.model - -import io.github.chrisimx.anyscan.LengthUnit -import io.github.chrisimx.anyscan.millimeters -import io.github.chrisimx.esclkt.BinaryRendering -import io.github.chrisimx.esclkt.CcdChannelEnumOrRaw -import io.github.chrisimx.esclkt.ContentTypeEnumOrRaw -import io.github.chrisimx.esclkt.EsclColorModeEnumOrRaw -import io.github.chrisimx.esclkt.EsclScanIntentEnumOrRaw -import io.github.chrisimx.esclkt.FeedDirection -import io.github.chrisimx.esclkt.InputSource -import io.github.chrisimx.esclkt.InputSourceCaps -import io.github.chrisimx.esclkt.ScanRegion -import io.github.chrisimx.esclkt.ScanRegions -import io.github.chrisimx.esclkt.ScanSettings -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: EsclScanIntentEnumOrRaw?, - val scanRegions: StatelessImmutableScanRegion?, - val documentFormatExt: String?, - val contentType: ContentTypeEnumOrRaw?, - val inputSource: InputSource?, - val xResolution: UInt, - val yResolution: UInt, - val colorMode: EsclColorModeEnumOrRaw?, - 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/androidApp/src/main/java/io/github/chrisimx/scanbridge/data/model/LegacySessionV2.kt b/androidApp/src/main/java/io/github/chrisimx/scanbridge/data/model/LegacySessionV2.kt deleted file mode 100644 index e1aa2c04..00000000 --- a/androidApp/src/main/java/io/github/chrisimx/scanbridge/data/model/LegacySessionV2.kt +++ /dev/null @@ -1,63 +0,0 @@ -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.model.ScanRelativeRotation -import kotlinx.serialization.Serializable -import kotlinx.serialization.json.Json -import timber.log.Timber - -@Serializable -data class LegacyScanMetadata( - val filePath: String, - val originalScanSettings: ScanSettings, - val rotation: ScanRelativeRotation = ScanRelativeRotation.Original -) - -@Serializable -data class LegacySessionV2( - val sessionID: String, - val scannedPages: List, - val scanSettings: ScanSettings?, - val tmpFiles: List -) { - companion object { - 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(caps)) - } catch (e: Exception) { - Result.failure(e) - } - } - } -} - -@Serializable -data class LegacySessionV1( - val sessionID: String, - val scannedPages: List>, - val scanSettings: StatelessImmutableESCLScanSettingsState?, - val tmpFiles: List -) { - fun migrateToNew(caps: ScannerCapabilities?): LegacySessionV2 { - val scannedPages = this.scannedPages.map { LegacyScanMetadata(it.first, it.second) } - - 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 LegacySessionV2(this.sessionID, scannedPages, scanSettings, this.tmpFiles) - } -} diff --git a/androidApp/src/main/java/io/github/chrisimx/scanbridge/data/ui/ScanningScreenData.kt b/androidApp/src/main/java/io/github/chrisimx/scanbridge/data/ui/ScanningScreenData.kt index dd719756..f71f678b 100644 --- a/androidApp/src/main/java/io/github/chrisimx/scanbridge/data/ui/ScanningScreenData.kt +++ b/androidApp/src/main/java/io/github/chrisimx/scanbridge/data/ui/ScanningScreenData.kt @@ -24,6 +24,7 @@ import androidx.compose.runtime.State import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import io.github.chrisimx.anyscan.CommonScannerCapabilities +import io.github.chrisimx.scanbridge.ScanSettingsComposableStateHolder import java.io.File import kotlin.uuid.Uuid diff --git a/androidApp/src/main/java/io/github/chrisimx/scanbridge/data/ui/ScanningScreenViewModel.kt b/androidApp/src/main/java/io/github/chrisimx/scanbridge/data/ui/ScanningScreenViewModel.kt index 5a61584a..f1569f03 100644 --- a/androidApp/src/main/java/io/github/chrisimx/scanbridge/data/ui/ScanningScreenViewModel.kt +++ b/androidApp/src/main/java/io/github/chrisimx/scanbridge/data/ui/ScanningScreenViewModel.kt @@ -44,6 +44,7 @@ import io.github.chrisimx.anyscan.ScanSettingsMap import io.github.chrisimx.anyscan.ScannerConcept import io.github.chrisimx.anyscan.inches import io.github.chrisimx.scanbridge.R +import io.github.chrisimx.scanbridge.ScanSettingsComposableStateHolder import io.github.chrisimx.scanbridge.appsettings.AppSettingsRepository import io.github.chrisimx.scanbridge.db.ScanBridgeDb import io.github.chrisimx.scanbridge.db.entities.AppSettings @@ -51,12 +52,12 @@ import io.github.chrisimx.scanbridge.db.entities.LastUsedScanSettings import io.github.chrisimx.scanbridge.db.entities.ScannedPage import io.github.chrisimx.scanbridge.db.entities.Session import io.github.chrisimx.scanbridge.db.entities.TempFile +import io.github.chrisimx.scanbridge.initialscansettings.InitialScanSettingsProvider import io.github.chrisimx.scanbridge.model.ScanRelativeRotation import io.github.chrisimx.scanbridge.model.ScanSettingsEnterableDataV1 import io.github.chrisimx.scanbridge.model.ScannerHandle import io.github.chrisimx.scanbridge.model.scannerCapabilities import io.github.chrisimx.scanbridge.model.toggleRotation -import io.github.chrisimx.scanbridge.ports.InitialScanSettingsProvider import io.github.chrisimx.scanbridge.ports.ScannerCapabilitiesResult import io.github.chrisimx.scanbridge.ports.ScannerConnectionSettings import io.github.chrisimx.scanbridge.savelastusedscansettings.LastUsedScanSettingsRepository @@ -82,6 +83,7 @@ import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.filter +import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.onEach @@ -89,8 +91,7 @@ import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import org.koin.core.annotation.InjectedParam -import org.koin.core.parameter.parametersOf -import org.koin.mp.KoinPlatform.getKoin +import org.koin.core.scope.Scope import timber.log.Timber enum class ScanningScreenEvent { @@ -115,7 +116,8 @@ class ScanningScreenViewModel( val appSettingsRepository: AppSettingsRepository, val lastUsedScanSettingsRepo: LastUsedScanSettingsRepository, val initialScanSettingsProvider: InitialScanSettingsProvider, - val startScanUseCase: StartScanUseCase + val startScanUseCase: StartScanUseCase, + val koinScope: Scope ) : AndroidViewModel(application) { private val _scanningScreenData = ScanningScreenData( @@ -331,19 +333,20 @@ class ScanningScreenViewModel( } } - val defaultScanSettingsUIData = ScanSettingsEnterableDataV1() - if (storedSession != null) { - _scanningScreenData.scanSettingsVM.value = getKoin().get { - parametersOf( - MutableStateFlow(caps).asStateFlow(), - session.map { it?.currentScanSettings ?: storedSession.currentScanSettings } - .stateIn(viewModelScope, SharingStarted.Lazily, storedSession.currentScanSettings), - storedSession.currentSettingsUIData?.copy() ?: defaultScanSettingsUIData, - updateSettings, - viewModelScope - ) - } + _scanningScreenData.scanSettingsVM.value = ScanSettingsComposableStateHolder( + MutableStateFlow(caps).asStateFlow(), + session.map { it?.currentScanSettings ?: storedSession.currentScanSettings!! } + .stateIn(viewModelScope, SharingStarted.Lazily, storedSession.currentScanSettings!!), + storedSession.currentSettingsUIData?.copy(), + updateSettings, + null, + viewModelScope, + koinScope.get(), + koinScope.get(), + koinScope.get(), + koinScope.get() + ) } else { // Try to load saved scan settings first, fallback to defaults if none exist val savedSettings = lastUsedScanSettingsRepo.getLastUsedScanSettings() @@ -374,16 +377,19 @@ class ScanningScreenViewModel( ) ) - _scanningScreenData.scanSettingsVM.value = getKoin().get { - parametersOf( - MutableStateFlow(caps).asStateFlow(), - session.map { it?.currentScanSettings ?: initialSettings } - .stateIn(viewModelScope, SharingStarted.Lazily, initialSettings), - lastUsedScanSettingsEnterable ?: defaultScanSettingsUIData, - updateSettings, - viewModelScope - ) - } + _scanningScreenData.scanSettingsVM.value = ScanSettingsComposableStateHolder( + MutableStateFlow(caps).asStateFlow(), + session.map { it?.currentScanSettings ?: initialSettings } + .stateIn(viewModelScope, SharingStarted.Lazily, initialSettings), + lastUsedScanSettingsEnterable, + updateSettings, + { initialSettings }, + viewModelScope, + koinScope.get(), + koinScope.get(), + koinScope.get(), + koinScope.get() + ) } // Subscribe to scan settings ui data changes so that we can save them to the database diff --git a/androidApp/src/main/java/io/github/chrisimx/scanbridge/uicomponents/settings/CheckboxSetting.kt b/androidApp/src/main/java/io/github/chrisimx/scanbridge/uicomponents/settings/CheckboxSetting.kt index 668de413..2e5f1a89 100644 --- a/androidApp/src/main/java/io/github/chrisimx/scanbridge/uicomponents/settings/CheckboxSetting.kt +++ b/androidApp/src/main/java/io/github/chrisimx/scanbridge/uicomponents/settings/CheckboxSetting.kt @@ -8,21 +8,20 @@ import androidx.compose.material3.Checkbox import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable -import androidx.compose.runtime.getValue -import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.compose.ui.semantics.Role import androidx.compose.ui.unit.dp import androidx.constraintlayout.compose.ConstraintLayout import androidx.constraintlayout.compose.Dimension +import org.jetbrains.compose.resources.StringResource @Composable fun CheckboxSetting( settingsText: String, - helpText: Int, + helpText: StringResource, checked: Boolean, setChecked: (Boolean) -> Unit, - onInformationRequested: (Int) -> Unit + onInformationRequested: (StringResource) -> Unit ) { ConstraintLayout( Modifier diff --git a/androidApp/src/main/java/io/github/chrisimx/scanbridge/uicomponents/settings/UIntSetting.kt b/androidApp/src/main/java/io/github/chrisimx/scanbridge/uicomponents/settings/UIntSetting.kt index a106ffcc..1949d763 100644 --- a/androidApp/src/main/java/io/github/chrisimx/scanbridge/uicomponents/settings/UIntSetting.kt +++ b/androidApp/src/main/java/io/github/chrisimx/scanbridge/uicomponents/settings/UIntSetting.kt @@ -38,6 +38,7 @@ import androidx.compose.ui.unit.dp import androidx.constraintlayout.compose.ConstraintLayout import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext +import org.jetbrains.compose.resources.StringResource import timber.log.Timber @Composable @@ -45,8 +46,8 @@ fun UIntSettingBase( initialTimeout: suspend () -> UInt, settingName: String, placeholder: String, - help: Int, - onHelpRequested: (Int) -> Unit, + help: StringResource, + onHelpRequested: (StringResource) -> Unit, changeSetting: (UInt) -> Unit, min: UInt, max: UInt @@ -123,8 +124,8 @@ fun UIntSetting( initialTimeout: suspend () -> UInt, default: UInt, settingName: String, - help: Int, - onHelpRequested: (Int) -> Unit, + help: StringResource, + onHelpRequested: (StringResource) -> Unit, setSetting: (UInt) -> Unit, min: UInt = 0u, max: UInt = UInt.MAX_VALUE diff --git a/androidApp/src/main/java/io/github/chrisimx/scanbridge/util/ESCLKtExtensions.kt b/androidApp/src/main/java/io/github/chrisimx/scanbridge/util/ESCLKtExtensions.kt index 1368d1ad..e67cb734 100644 --- a/androidApp/src/main/java/io/github/chrisimx/scanbridge/util/ESCLKtExtensions.kt +++ b/androidApp/src/main/java/io/github/chrisimx/scanbridge/util/ESCLKtExtensions.kt @@ -20,11 +20,11 @@ package io.github.chrisimx.scanbridge.util import android.content.Context -import android.icu.text.DecimalFormat import androidx.compose.runtime.Composable import androidx.compose.ui.res.stringResource import io.github.chrisimx.esclkt.JobState import io.github.chrisimx.scanbridge.R +import io.github.chrisimx.scanbridge.model.UIInputSourceType fun JobState?.toJobStateString(context: Context): String = when (this) { JobState.Canceled -> context.getString(R.string.job_canceled) @@ -35,12 +35,6 @@ fun JobState?.toJobStateString(context: Context): String = when (this) { null -> context.getString(R.string.job_state_cannot_be_retrieved) } -fun String.toDoubleLocalized(): Double? = runCatching { - DecimalFormat.getInstance().parse(this).toDouble() -}.getOrNull() - -fun Double.toStringLocalized(): String = DecimalFormat.getInstance().format(this) - @Composable fun UIInputSourceType.toReadableString(): String = when (this) { UIInputSourceType.PLATEN -> diff --git a/composeUI/src/androidMain/res/values-de/strings.xml b/composeUI/src/androidMain/res/values-de/strings.xml index 9d063ed9..e413dc03 100644 --- a/composeUI/src/androidMain/res/values-de/strings.xml +++ b/composeUI/src/androidMain/res/values-de/strings.xml @@ -92,7 +92,7 @@ Entdeckte Scanner Gespeicherte Scanner Max. Seitenzahl für pro PDF - "Da der Arbeitsspeicher von Smartphones begrenzt ist kann es schwierig werden sehr große PDFs zu erzeugen. Um Abstürze zu verhindern, teilt ScanBridge einen PDF-Export auf mehrere PDFs auf, wenn die hier festgelegte Grenze überschritten würde. In diesem Fall werden die PDFs in einem ZIP-Archiv zusammengefasst. Falls gewünscht, können die PDFs dann im Nachhinein zusammengeführt werden. \n\nWarnung: Wenn diese Option zu weit erhöht wird und sehr viele Seiten als PDF exportiert werden sollen, kann es zu Abstürzen kommen." + Da der Arbeitsspeicher von Smartphones begrenzt ist kann es schwierig werden sehr große PDFs zu erzeugen. Um Abstürze zu verhindern, teilt ScanBridge einen PDF-Export auf mehrere PDFs auf, wenn die hier festgelegte Grenze überschritten würde. In diesem Fall werden die PDFs in einem ZIP-Archiv zusammengefasst. Falls gewünscht, können die PDFs dann im Nachhinein zusammengeführt werden. \n\nWarnung: Wenn diese Option zu weit erhöht wird und sehr viele Seiten als PDF exportiert werden sollen, kann es zu Abstürzen kommen." HTTPS-Zertifikatsüberprüfung umgehen Diese Einstellung deaktiviert alle Zertifikatsprüfungen bei HTTPS-Verbindungen. Jedes Zertifikat wird folglich akzeptiert, egal ob es ungültig oder selbstsigniert ist. Diese Einstellung sollte nicht in unsicheren Netwerken verwendet werden, denn durch sie wird ein Man-in-the-Middle-Angriff leicht durchführbar.\n\nIn den meisten Fällen werden Scanner in privaten Netzwerken oder über VPN-Verbindungen verwendet, wo Authentizität in der Regel keine große Rolle spielt. In solchen Umgebungen ist es unwahrscheinlich, dass das Deaktivieren der Zertifikatsprüfung ein echtes Sicherheitsrisiko darstellt. Und falls Vertraulichkeit oder Authentizität doch eine Rolle spielen, hilft selbst HTTPS nur begrenzt – denn eSCL bietet keinerlei Client-Authentifizierung. Jeder mit Netzwerkzugriff auf den Scanner kann potenziell auf gescannte Seiten zugreifen. Speichern der zuletzt verwendeten Scaneinstellungen diff --git a/composeUI/src/commonMain/composeResources/values-de/strings.xml b/composeUI/src/commonMain/composeResources/values-de/strings.xml index c0c6bd0b..4203b17a 100644 --- a/composeUI/src/commonMain/composeResources/values-de/strings.xml +++ b/composeUI/src/commonMain/composeResources/values-de/strings.xml @@ -92,7 +92,7 @@ Entdeckte Scanner Gespeicherte Scanner Max. Seitenzahl für pro PDF - "Da der Arbeitsspeicher von Smartphones begrenzt ist kann es schwierig werden sehr große PDFs zu erzeugen. Um Abstürze zu verhindern, teilt ScanBridge einen PDF-Export auf mehrere PDFs auf, wenn die hier festgelegte Grenze überschritten würde. In diesem Fall werden die PDFs in einem ZIP-Archiv zusammengefasst. Falls gewünscht, können die PDFs dann im Nachhinein zusammengeführt werden. \n\nWarnung: Wenn diese Option zu weit erhöht wird und sehr viele Seiten als PDF exportiert werden sollen, kann es zu Abstürzen kommen." + Da der Arbeitsspeicher von Smartphones begrenzt ist kann es schwierig werden sehr große PDFs zu erzeugen. Um Abstürze zu verhindern, teilt ScanBridge einen PDF-Export auf mehrere PDFs auf, wenn die hier festgelegte Grenze überschritten würde. In diesem Fall werden die PDFs in einem ZIP-Archiv zusammengefasst. Falls gewünscht, können die PDFs dann im Nachhinein zusammengeführt werden. \n\nWarnung: Wenn diese Option zu weit erhöht wird und sehr viele Seiten als PDF exportiert werden sollen, kann es zu Abstürzen kommen." HTTPS-Zertifikatsüberprüfung umgehen Diese Einstellung deaktiviert alle Zertifikatsprüfungen bei HTTPS-Verbindungen. Jedes Zertifikat wird folglich akzeptiert, egal ob es ungültig oder selbstsigniert ist. Diese Einstellung sollte nicht in unsicheren Netwerken verwendet werden, denn durch sie wird ein Man-in-the-Middle-Angriff leicht durchführbar.\n\nIn den meisten Fällen werden Scanner in privaten Netzwerken oder über VPN-Verbindungen verwendet, wo Authentizität in der Regel keine große Rolle spielt. In solchen Umgebungen ist es unwahrscheinlich, dass das Deaktivieren der Zertifikatsprüfung ein echtes Sicherheitsrisiko darstellt. Und falls Vertraulichkeit oder Authentizität doch eine Rolle spielen, hilft selbst HTTPS nur begrenzt – denn eSCL bietet keinerlei Client-Authentifizierung. Jeder mit Netzwerkzugriff auf den Scanner kann potenziell auf gescannte Seiten zugreifen. Speichern der zuletzt verwendeten Scaneinstellungen @@ -133,4 +133,6 @@ 3D-Objekt Visitenkarte Protokoll + Initiale Scaneinstellungen + Diese Einstellung definiert die bevorzugten anfänglichen Scaneinstellungen, die beim Starten einer neuen Scansitzung verwendet werden.\n\nIm Menü kannst du die gewünschten Optionen festlegen. Wenn deine Auswahl vom Scanner unterstützt wird, wird sie direkt verwendet. Andernfalls werden die Einstellungen so angepasst, dass sie vom Scanner unterstützt werden und deiner Auswahl möglichst nahekommen.\n\nWenn die Option „Speichern der zuletzt verwendeten Scaneinstellungen“ aktiviert ist, werden stattdessen die zuletzt verwendeten Scaneinstellungen verwendet, sofern vorhanden. \ No newline at end of file diff --git a/composeUI/src/commonMain/composeResources/values-it/strings.xml b/composeUI/src/commonMain/composeResources/values-it/strings.xml index d3ba5551..6da44d73 100644 --- a/composeUI/src/commonMain/composeResources/values-it/strings.xml +++ b/composeUI/src/commonMain/composeResources/values-it/strings.xml @@ -125,4 +125,12 @@ Elimina scanner personalizzato Colore (24 bit) Scala di grigi (8 bit) + Documento + + Foto + Anteprima + Oggetto 3D + Biglietto da visita + Impostazioni di scansione iniziali + Questa impostazione definisce le impostazioni di scansione iniziali preferite che vengono utilizzate quando si avvia una nuova sessione di scansione.\n\nNel menu puoi impostare le opzioni desiderate. Se la tua selezione è supportata dallo scanner, verrà utilizzata direttamente. In caso contrario, le impostazioni verranno adattate in modo da essere supportate dallo scanner e il più possibile vicine alla tua selezione.\n\nSe l'opzione „Ricorda le impostazioni di scansione“ è attivata, verranno invece utilizzate le impostazioni di scansione utilizzate più recentemente, se disponibili. \ No newline at end of file diff --git a/composeUI/src/commonMain/composeResources/values/strings.xml b/composeUI/src/commonMain/composeResources/values/strings.xml index d22034a8..0841758f 100644 --- a/composeUI/src/commonMain/composeResources/values/strings.xml +++ b/composeUI/src/commonMain/composeResources/values/strings.xml @@ -135,4 +135,6 @@ 3D Object Business Card Protocol + Initial scan settings + This setting determines the initial scan settings used when starting a new scan session.\n\nIn the panel that opens, you can set the options you prefer. When your choice is supported by the scanner, it will be used. Else the closest supported settings will be used.\n\nThis setting can be overriden by the last used scan settings when the "Remeber scan settings" option is on. \ No newline at end of file diff --git a/core/build.gradle.kts b/core/build.gradle.kts index 0124f3c0..fd76013b 100644 --- a/core/build.gradle.kts +++ b/core/build.gradle.kts @@ -30,7 +30,7 @@ kotlin { compilerOptions { jvmTarget.set( - org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_17 + JvmTarget.JVM_17 ) } @@ -75,6 +75,13 @@ kotlin { api(libs.ktor.client.okhttp) } + val jvmAndAndroid by creating { + dependsOn(commonMain.get()) + } + + androidMain.get().dependsOn(jvmAndAndroid) + jvmMain.get().dependsOn(jvmAndAndroid) + getByName("androidDeviceTest") { dependencies { implementation(kotlin("test")) diff --git a/core/schemas/io.github.chrisimx.scanbridge.db.ScanBridgeDb/8.json b/core/schemas/io.github.chrisimx.scanbridge.db.ScanBridgeDb/8.json new file mode 100644 index 00000000..6bd27fbd --- /dev/null +++ b/core/schemas/io.github.chrisimx.scanbridge.db.ScanBridgeDb/8.json @@ -0,0 +1,370 @@ +{ + "formatVersion": 1, + "database": { + "version": 8, + "identityHash": "b54363f362957b7525c1786bef2d5a19", + "entities": [ + { + "tableName": "customscanners", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`uuid` TEXT NOT NULL, `name` TEXT NOT NULL, `url` TEXT NOT NULL, `protocolIdentifier` TEXT NOT NULL DEFAULT 'eSCL', PRIMARY KEY(`uuid`))", + "fields": [ + { + "fieldPath": "uuid", + "columnName": "uuid", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "url", + "columnName": "url", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "protocolIdentifier", + "columnName": "protocolIdentifier", + "affinity": "TEXT", + "notNull": true, + "defaultValue": "'eSCL'" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "uuid" + ] + } + }, + { + "tableName": "scannedpages", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`scanId` TEXT NOT NULL, `ownerSessionId` TEXT NOT NULL, `filePath` TEXT NOT NULL, `originalScanSettings` TEXT NOT NULL, `rotation` TEXT NOT NULL, `orderIndex` INTEGER NOT NULL, `outputName` TEXT, PRIMARY KEY(`scanId`), FOREIGN KEY(`ownerSessionId`) REFERENCES `sessions`(`sessionId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "scanId", + "columnName": "scanId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "ownerSessionId", + "columnName": "ownerSessionId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "filePath", + "columnName": "filePath", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "originalScanSettings", + "columnName": "originalScanSettings", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "rotation", + "columnName": "rotation", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "orderIndex", + "columnName": "orderIndex", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "outputName", + "columnName": "outputName", + "affinity": "TEXT" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "scanId" + ] + }, + "indices": [ + { + "name": "index_scannedpages_ownerSessionId", + "unique": false, + "columnNames": [ + "ownerSessionId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_scannedpages_ownerSessionId` ON `${TABLE_NAME}` (`ownerSessionId`)" + }, + { + "name": "index_scannedpages_ownerSessionId_orderIndex", + "unique": true, + "columnNames": [ + "ownerSessionId", + "orderIndex" + ], + "orders": [], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_scannedpages_ownerSessionId_orderIndex` ON `${TABLE_NAME}` (`ownerSessionId`, `orderIndex`)" + } + ], + "foreignKeys": [ + { + "table": "sessions", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "ownerSessionId" + ], + "referencedColumns": [ + "sessionId" + ] + } + ] + }, + { + "tableName": "sessions", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`sessionId` TEXT NOT NULL, `currentScanSettings` TEXT, `currentSettingsUIData` TEXT DEFAULT null, `currentPage` INTEGER NOT NULL, PRIMARY KEY(`sessionId`))", + "fields": [ + { + "fieldPath": "sessionId", + "columnName": "sessionId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "currentScanSettings", + "columnName": "currentScanSettings", + "affinity": "TEXT" + }, + { + "fieldPath": "currentSettingsUIData", + "columnName": "currentSettingsUIData", + "affinity": "TEXT", + "defaultValue": "null" + }, + { + "fieldPath": "currentPage", + "columnName": "currentPage", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "sessionId" + ] + } + }, + { + "tableName": "tempfiles", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`tempFileId` TEXT NOT NULL, `ownerSessionId` TEXT NOT NULL, `path` TEXT NOT NULL, PRIMARY KEY(`tempFileId`), FOREIGN KEY(`ownerSessionId`) REFERENCES `sessions`(`sessionId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "tempFileId", + "columnName": "tempFileId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "ownerSessionId", + "columnName": "ownerSessionId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "path", + "columnName": "path", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "tempFileId" + ] + }, + "indices": [ + { + "name": "index_tempfiles_ownerSessionId", + "unique": false, + "columnNames": [ + "ownerSessionId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_tempfiles_ownerSessionId` ON `${TABLE_NAME}` (`ownerSessionId`)" + } + ], + "foreignKeys": [ + { + "table": "sessions", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "ownerSessionId" + ], + "referencedColumns": [ + "sessionId" + ] + } + ] + }, + { + "tableName": "lastroute", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`route` TEXT NOT NULL, `id` INTEGER NOT NULL, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "route", + "columnName": "route", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + } + }, + { + "tableName": "executedmigrationtoroom", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`migrationId` TEXT NOT NULL, PRIMARY KEY(`migrationId`))", + "fields": [ + { + "fieldPath": "migrationId", + "columnName": "migrationId", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "migrationId" + ] + } + }, + { + "tableName": "shownstartupmessages", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`message` TEXT NOT NULL, PRIMARY KEY(`message`))", + "fields": [ + { + "fieldPath": "message", + "columnName": "message", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "message" + ] + } + }, + { + "tableName": "appsettings", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `writeDebugLogs` INTEGER NOT NULL, `disableCertValidation` INTEGER NOT NULL, `scanningResponseTimeoutInS` INTEGER NOT NULL, `chunkSizeForPDFExport` INTEGER NOT NULL, `rememberScanSettings` INTEGER NOT NULL, `preferredInitialScanSettings` TEXT NOT NULL, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "writeDebugLogs", + "columnName": "writeDebugLogs", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "disableCertValidation", + "columnName": "disableCertValidation", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "scanningResponseTimeoutInS", + "columnName": "scanningResponseTimeoutInS", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "chunkSizeForPDFExport", + "columnName": "chunkSizeForPDFExport", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "rememberScanSettings", + "columnName": "rememberScanSettings", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "preferredInitialScanSettings", + "columnName": "preferredInitialScanSettings", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + } + }, + { + "tableName": "lastusedscansettings", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `lastUsedScanSettings` TEXT NOT NULL, `lastUsedScanSettingsEnterable` TEXT NOT NULL, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastUsedScanSettings", + "columnName": "lastUsedScanSettings", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "lastUsedScanSettingsEnterable", + "columnName": "lastUsedScanSettingsEnterable", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + } + } + ], + "setupQueries": [ + "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", + "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, 'b54363f362957b7525c1786bef2d5a19')" + ] + } +} \ No newline at end of file diff --git a/androidApp/src/main/java/io/github/chrisimx/scanbridge/data/ui/ScanSettingsComposableStateHolder.kt b/core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/ScanSettingsComposableStateHolder.kt similarity index 63% rename from androidApp/src/main/java/io/github/chrisimx/scanbridge/data/ui/ScanSettingsComposableStateHolder.kt rename to core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/ScanSettingsComposableStateHolder.kt index 13bb94cf..ee36dbdc 100644 --- a/androidApp/src/main/java/io/github/chrisimx/scanbridge/data/ui/ScanSettingsComposableStateHolder.kt +++ b/core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/ScanSettingsComposableStateHolder.kt @@ -17,9 +17,8 @@ * SPDX-License-Identifier: GPL-3.0-or-later */ -package io.github.chrisimx.scanbridge.data.ui +package io.github.chrisimx.scanbridge -import com.google.protobuf.LazyStringArrayList.emptyList import io.github.chrisimx.anyscan.Area import io.github.chrisimx.anyscan.CommonInputSourceCaps import io.github.chrisimx.anyscan.CommonInputSourceType @@ -33,16 +32,18 @@ import io.github.chrisimx.anyscan.ScannerConcept import io.github.chrisimx.anyscan.SettingValue import io.github.chrisimx.anyscan.inches import io.github.chrisimx.anyscan.millimeters -import io.github.chrisimx.scanbridge.PaperFormat -import io.github.chrisimx.scanbridge.PaperFormatProvider +import io.github.chrisimx.anyscan.minus +import io.github.chrisimx.anyscan.plus +import io.github.chrisimx.scanbridge.localization.NumberFormatter +import io.github.chrisimx.scanbridge.localization.parseDouble import io.github.chrisimx.scanbridge.model.Locale import io.github.chrisimx.scanbridge.model.NumberValidationResult import io.github.chrisimx.scanbridge.model.ScanSettingsEnterableDataV1 +import io.github.chrisimx.scanbridge.model.UIInputSourceType +import io.github.chrisimx.scanbridge.model.toUIInputSourceType import io.github.chrisimx.scanbridge.ports.LocaleProvider -import io.github.chrisimx.scanbridge.util.UIInputSourceType +import io.github.chrisimx.scanbridge.ports.ScanBridgeLoggerFactory import io.github.chrisimx.scanbridge.util.derived -import io.github.chrisimx.scanbridge.util.toDoubleLocalized -import io.github.chrisimx.scanbridge.util.toUIInputSourceType import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharingStarted @@ -59,7 +60,6 @@ import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch import org.koin.core.annotation.InjectedParam -import timber.log.Timber enum class ScanSettingsLengthUnit { INCH, @@ -72,17 +72,27 @@ class ScanSettingsComposableStateHolder( @InjectedParam val scanSettings: StateFlow, @InjectedParam - private val initialScanSettingsData: ScanSettingsEnterableDataV1, + private val initialEnterableData: ScanSettingsEnterableDataV1?, @InjectedParam private val updateSettings: suspend (CommonScanSettingsEditor.() -> Unit) -> Unit, @InjectedParam + private val getInitialSettingsAfterCoercing: (suspend () -> CommonScanSettings)?, + @InjectedParam private val coroutineScope: CoroutineScope, private val localeProvider: LocaleProvider, - private val paperFormatProvider: PaperFormatProvider + private val paperFormatProvider: PaperFormatProvider, + private val numberFormatter: NumberFormatter, + loggerFactory: ScanBridgeLoggerFactory ) { - private val _uiState = MutableStateFlow(initialScanSettingsData) - val uiState: StateFlow = _uiState.asStateFlow() + private val logger = loggerFactory.withClass(this::class) + + val userLengthUnit = localeProvider.locale.derived(coroutineScope) { + unitByLocale(it) + } + + private val _uiState = MutableStateFlow(initialEnterableData) + val uiState: StateFlow = _uiState.asStateFlow() val inputSourceOptions: StateFlow> = capabilities.derived(coroutineScope) { caps -> caps.inputSources.map { @@ -119,18 +129,68 @@ class ScanSettingsComposableStateHolder( capabilities.value.inputSources.first() ) + private suspend fun inferInitialScanSettingsData(): ScanSettingsEnterableDataV1 { + val currentScanSettings = getInitialSettingsAfterCoercing?.invoke() ?: return ScanSettingsEnterableDataV1() + val paperFormats = paperFormatProvider.formats.value + + val selectedScanArea = currentScanSettings.setting[ScannerConcept.ScanRegion]?.value + ?: return ScanSettingsEnterableDataV1( + customMenuEnabled = false, + maximumSize = false + ) + + // Is the selected scan area a known paper format? + val isKnownPaperFormat = + paperFormats + .any { + it.area.equalsArea(selectedScanArea) + } + + if (isKnownPaperFormat) { + return ScanSettingsEnterableDataV1( + customMenuEnabled = false, + maximumSize = false + ) + } + + // Is the selected scan area the maximum scan area? + val caps = capabilities.value + val inputCaps = caps.getInputSourceCaps(currentScanSettings.inputSource!!) + val scanRegionParam = inputCaps?.furtherOptions[ScannerConcept.ScanRegion] as ScanSettingParam.ScanSettingRegionParam? + val maxScanRegion = scanRegionParam?.maxArea?.value + + if (maxScanRegion != null && maxScanRegion in selectedScanArea) { + return ScanSettingsEnterableDataV1( + customMenuEnabled = false, + maximumSize = true + ) + } + + // If it is not a known paper format, and the selected scan area is not the maximum scan area, + // we assume it is a custom scan area. + val heightInUserUnit = toUserUnit(selectedScanArea.height) + val widthInUserUnit = toUserUnit(selectedScanArea.width) + + return ScanSettingsEnterableDataV1( + heightString = numberFormatter.formatDouble(heightInUserUnit), + widthString = numberFormatter.formatDouble(widthInUserUnit), + customMenuEnabled = true, + maximumSize = false + ) + } + private fun validateDimensionValue(valueString: String, getDimension: (Area) -> LengthUnit): NumberValidationResult { if (valueString.isBlank()) { return NumberValidationResult.NotANumber } - val dimension = valueString.toDoubleLocalized() ?: return NumberValidationResult.NotANumber + val dimension = valueString.parseDouble(numberFormatter) ?: return NumberValidationResult.NotANumber val regionParam = capabilities.value.inputSources.firstOrNull { it.inputSourceType == scanSettings.value.inputSource }?.furtherOptions?.get(ScannerConcept.ScanRegion) as? ScanSettingParam.ScanSettingRegionParam - val lengthInUnit = when (lengthUnit.value) { + val lengthInUnit = when (userLengthUnit.value) { ScanSettingsLengthUnit.INCH -> dimension.inches() ScanSettingsLengthUnit.MILLIMETER -> dimension.millimeters() } @@ -139,8 +199,8 @@ class ScanSettingsComposableStateHolder( return NumberValidationResult.Success(lengthInUnit) } - val maxDimension = toUserUnit(lengthUnit.value, getDimension(regionParam.maxArea.value)) - val minDimension = toUserUnit(lengthUnit.value, getDimension(regionParam.minArea.value)) + val maxDimension = toUserUnit(getDimension(regionParam.maxArea.value) + 0.1.millimeters()) + val minDimension = toUserUnit(getDimension(regionParam.minArea.value) - 0.1.millimeters()) if (dimension !in minDimension..maxDimension) { return NumberValidationResult.OutOfRange(minDimension, maxDimension) @@ -150,12 +210,16 @@ class ScanSettingsComposableStateHolder( } val validationResultHeight: StateFlow = combine(uiState, capabilities) { settings, caps -> - validateDimensionValue(settings.heightString) { it.height } - }.stateIn(coroutineScope, SharingStarted.Lazily, NumberValidationResult.NotANumber) + settings to caps + }.filter { (settings, caps) -> settings != null } + .map { (settings, caps) -> validateDimensionValue(settings!!.heightString) { it.height } } + .stateIn(coroutineScope, SharingStarted.Lazily, NumberValidationResult.NotANumber) val validationResultWidth: StateFlow = combine(uiState, capabilities) { settings, caps -> - validateDimensionValue(settings.widthString) { it.width } - }.stateIn(coroutineScope, SharingStarted.Lazily, NumberValidationResult.NotANumber) + settings to caps + }.filter { (settings, caps) -> settings != null } + .map { (settings, caps) -> validateDimensionValue(settings!!.widthString) { it.width } } + .stateIn(coroutineScope, SharingStarted.Lazily, NumberValidationResult.NotANumber) val availableParameters = selectedInputSourceCaps.derived(coroutineScope) { it.furtherOptions @@ -182,48 +246,53 @@ class ScanSettingsComposableStateHolder( } }.stateIn(coroutineScope, SharingStarted.Lazily, emptyList()) - val lengthUnit = localeProvider.locale.derived(coroutineScope) { - unitByLocale(it) - } - private fun unitByLocale(locale: Locale): ScanSettingsLengthUnit = if (locale.country in setOf("US", "LR", "MM")) { ScanSettingsLengthUnit.INCH } else { ScanSettingsLengthUnit.MILLIMETER } - private fun toUserUnit(unit: ScanSettingsLengthUnit, length: LengthUnit): Double = when (unit) { + private fun toUserUnit(length: LengthUnit): Double = when (userLengthUnit.value) { ScanSettingsLengthUnit.INCH -> length.toInches().value ScanSettingsLengthUnit.MILLIMETER -> length.toMillimeters().value } init { - combine(validationResultHeight, validationResultWidth, _uiState) { height, width, ui -> - Triple(height, width, ui.customMenuEnabled) - }.mapNotNull { (height, width, customMenuEnabled) -> - if (customMenuEnabled && height is NumberValidationResult.Success && width is NumberValidationResult.Success) { - height to width - } else { - null + if (initialEnterableData == null) { + coroutineScope.launch { + _uiState.value = inferInitialScanSettingsData() } - }.onEach { (height, width) -> - updateSettings { - this.set( - ScannerConcept.ScanRegion, - ScanRegionValue( - Area(height.value, width.value) + } + + combine(validationResultHeight, validationResultWidth, _uiState) { height, width, ui -> + Triple(height, width, ui?.customMenuEnabled) + }.filter { (_, _, customMenuEnabled) -> customMenuEnabled != null } + .mapNotNull { (height, width, customMenuEnabled) -> + if (customMenuEnabled!! && height is NumberValidationResult.Success && width is NumberValidationResult.Success) { + height to width + } else { + null + } + }.onEach { (height, width) -> + updateSettings { + this.set( + ScannerConcept.ScanRegion, + ScanRegionValue( + Area(height.value, width.value) + ) ) - ) - } - }.launchIn(coroutineScope) + } + }.launchIn(coroutineScope) _uiState - .map { it.maximumSize } + .mapNotNull { it?.maximumSize } .distinctUntilChanged() .combine(selectedInputSourceCaps) { maxSize, inputSourceCaps -> Pair(maxSize, inputSourceCaps) } .filter { it.first } .onEach { (maxSize, inputSourceCaps) -> - Timber.d("Maximum size flag set to $maxSize: This means we should set scanRegion to maximum") + logger.debug { + "Maximum size flag set to $maxSize: This means we should set scanRegion to maximum" + } val regionParam = inputSourceCaps .furtherOptions[ScannerConcept.ScanRegion] as ScanSettingParam.ScanSettingRegionParam? regionParam?.maxArea?.let { maxArea -> @@ -238,7 +307,9 @@ class ScanSettingsComposableStateHolder( val duplexCurrentlyActive = duplexSettingAvailable.value if (duplex && !duplexCurrentlyActive) { - Timber.d("Duplex can not be turned on because it is not available. Current duplex state: $duplexCurrentlyActive") + logger.debug { + "Duplex can not be turned on because it is not available. Current duplex state: $duplexCurrentlyActive" + } return } @@ -256,7 +327,9 @@ class ScanSettingsComposableStateHolder( } fun setInputSource(inputSource: UIInputSourceType) { - Timber.d("Input Source being set to $inputSource") + logger.debug { + "Input Source being set to $inputSource" + } coroutineScope.launch { updateSettings { @@ -288,7 +361,7 @@ class ScanSettingsComposableStateHolder( } fun setCustomMenuEnabled(enabled: Boolean) { - _uiState.update { it.copy(customMenuEnabled = enabled) } + _uiState.update { it?.copy(customMenuEnabled = enabled) } } fun setFormat(paperFormat: PaperFormat) { @@ -296,7 +369,7 @@ class ScanSettingsComposableStateHolder( height = paperFormat.height, width = paperFormat.width ) - _uiState.update { it.copy(maximumSize = false, customMenuEnabled = false) } + _uiState.update { it?.copy(maximumSize = false, customMenuEnabled = false) } coroutineScope.launch { updateSettings { @@ -307,19 +380,19 @@ class ScanSettingsComposableStateHolder( fun setCustomWidthTextFieldContent(content: String) { _uiState.update { - it.copy(widthString = content) + it?.copy(widthString = content) } } fun setCustomHeightTextFieldContent(content: String) { _uiState.update { - it.copy(heightString = content) + it?.copy(heightString = content) } } fun selectMaxRegion() { _uiState.update { - it.copy(maximumSize = true, customMenuEnabled = false) + it?.copy(maximumSize = true, customMenuEnabled = false) } } } diff --git a/core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/appsettings/AppSettingsRepository.kt b/core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/appsettings/AppSettingsRepository.kt index af2699c1..25e87954 100644 --- a/core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/appsettings/AppSettingsRepository.kt +++ b/core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/appsettings/AppSettingsRepository.kt @@ -1,5 +1,6 @@ package io.github.chrisimx.scanbridge.appsettings +import io.github.chrisimx.anyscan.CommonScanSettings import io.github.chrisimx.scanbridge.db.entities.AppSettings import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.map @@ -14,6 +15,18 @@ interface AppSettingsRepository { setAppSettings(AppSettings()) } + suspend fun getPreferredInitialScanSettings(): CommonScanSettings = getAppSettings().preferredInitialScanSettings + + fun getPreferredInitialScanSettingsFlow(): Flow = getAppSettingsFlow().map { + it.preferredInitialScanSettings + } + + suspend fun updatePreferredInitialScanSettings(updateOperation: CommonScanSettings.() -> CommonScanSettings) { + updateAppSettings { + copy(preferredInitialScanSettings = preferredInitialScanSettings.updateOperation()) + } + } + suspend fun setWriteDebugLogs(value: Boolean) = updateAppSettings { copy(writeDebugLogs = value) } diff --git a/core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/appsettings/AppSettingsViewModel.kt b/core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/appsettings/AppSettingsViewModel.kt index ca3b952b..647eebaf 100644 --- a/core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/appsettings/AppSettingsViewModel.kt +++ b/core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/appsettings/AppSettingsViewModel.kt @@ -1,14 +1,22 @@ package io.github.chrisimx.scanbridge.appsettings import com.rickclephas.kmp.observableviewmodel.ViewModel +import com.rickclephas.kmp.observableviewmodel.coroutineScope import com.rickclephas.kmp.observableviewmodel.launch import com.rickclephas.kmp.observableviewmodel.stateIn +import io.github.chrisimx.anyscan.CommonScanSettingsEditor +import io.github.chrisimx.scanbridge.ScanSettingsComposableStateHolder import io.github.chrisimx.scanbridge.buildinfo.BuildInfoProvider import io.github.chrisimx.scanbridge.db.entities.AppSettings +import io.github.chrisimx.scanbridge.initialscansettings.INITIAL_SCAN_SETTINGS_AVAILABLE_OPTIONS_CAPS +import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.map +import org.koin.core.scope.Scope -class AppSettingsViewModel(val appSettingsRepository: AppSettingsRepository, buildInfoProvider: BuildInfoProvider) : ViewModel() { +class AppSettingsViewModel(val appSettingsRepository: AppSettingsRepository, buildInfoProvider: BuildInfoProvider, koinScope: Scope) : + ViewModel() { val versionName = buildInfoProvider.versionName val versionCode = buildInfoProvider.versionCode val gitCommitHash = buildInfoProvider.commit @@ -28,6 +36,40 @@ class AppSettingsViewModel(val appSettingsRepository: AppSettingsRepository, bui val writeDebugLogs = appSettingsRepository.getWriteDebugLogsFlow() .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), AppSettings().writeDebugLogs) + private val _isPreferredInitialScanSettingsMenuVisible = MutableStateFlow(false) + val isPreferredInitialScanSettingsMenuVisible = _isPreferredInitialScanSettingsMenuVisible.asStateFlow() + + fun setPreferredInitialScanSettingsMenuVisibility(newVisibility: Boolean) { + _isPreferredInitialScanSettingsMenuVisible.value = newVisibility + } + + private val preferredInitialScanSettings = appSettingsRepository.getPreferredInitialScanSettingsFlow() + .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), AppSettings().preferredInitialScanSettings) + + val prefInitialScanSettingsUIStateHolder = ScanSettingsComposableStateHolder( + MutableStateFlow(INITIAL_SCAN_SETTINGS_AVAILABLE_OPTIONS_CAPS).asStateFlow(), + preferredInitialScanSettings, + null, + ::updatePreferredInitialScanSettings, + appSettingsRepository::getPreferredInitialScanSettings, + viewModelScope.coroutineScope, + koinScope.get(), + koinScope.get(), + koinScope.get(), + koinScope.get() + ) + + private suspend fun updatePreferredInitialScanSettings(updateOperation: CommonScanSettingsEditor.() -> Unit) { + appSettingsRepository.updatePreferredInitialScanSettings { + val editor = CommonScanSettingsEditor( + INITIAL_SCAN_SETTINGS_AVAILABLE_OPTIONS_CAPS, + this + ) + editor.updateOperation() + editor.build() + } + } + fun setDisableCertValidation(value: Boolean) { viewModelScope.launch { appSettingsRepository.setDisableCertValidation(value) diff --git a/core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/db/ScanBridgeDb.kt b/core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/db/ScanBridgeDb.kt index db4c03b7..a4d7e112 100644 --- a/core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/db/ScanBridgeDb.kt +++ b/core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/db/ScanBridgeDb.kt @@ -37,7 +37,7 @@ import io.github.chrisimx.scanbridge.db.typeconverters.UuidTypeConverter ShownStartupMessage::class, AppSettings::class, LastUsedScanSettings::class ], - version = 7, + version = 8, autoMigrations = [ AutoMigration( from = 1, diff --git a/core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/db/ScanBridgeDbFactory.kt b/core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/db/ScanBridgeDbFactory.kt index 3a97b7dd..db37f3c9 100644 --- a/core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/db/ScanBridgeDbFactory.kt +++ b/core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/db/ScanBridgeDbFactory.kt @@ -2,6 +2,7 @@ package io.github.chrisimx.scanbridge.db import ROOM_MIGRATION_4_5 import androidx.sqlite.driver.bundled.BundledSQLiteDriver +import io.github.chrisimx.scanbridge.db.migrations.ROOM_MIGRATION_7_8 import io.github.chrisimx.scanbridge.db.migrations.RoomMigrationVersion5To6 import io.github.chrisimx.scanbridge.db.migrations.RoomMigrationVersion6To7 import kotlinx.coroutines.Dispatchers @@ -21,7 +22,12 @@ class DefaultScanBridgeDbFactory( return dbBuilder .setDriver(BundledSQLiteDriver()) .setQueryCoroutineContext(Dispatchers.IO) - .addMigrations(ROOM_MIGRATION_4_5, migrationV5To6, migrationV6To7) + .addMigrations( + ROOM_MIGRATION_4_5, + migrationV5To6, + migrationV6To7, + ROOM_MIGRATION_7_8 + ) .build() } } diff --git a/core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/db/entities/AppSettings.kt b/core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/db/entities/AppSettings.kt index b009b70d..2b459e82 100644 --- a/core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/db/entities/AppSettings.kt +++ b/core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/db/entities/AppSettings.kt @@ -2,6 +2,9 @@ package io.github.chrisimx.scanbridge.db.entities import androidx.room.Entity import androidx.room.PrimaryKey +import io.github.chrisimx.anyscan.CommonScanSettings +import io.github.chrisimx.anyscan.CommonScanSettingsEditor +import io.github.chrisimx.scanbridge.initialscansettings.INITIAL_SCAN_SETTINGS_AVAILABLE_OPTIONS_CAPS @Entity(tableName = "appsettings") data class AppSettings( @@ -11,5 +14,9 @@ data class AppSettings( val disableCertValidation: Boolean = false, val scanningResponseTimeoutInS: Int = 25, val chunkSizeForPDFExport: Int = 50, - val rememberScanSettings: Boolean = true + val rememberScanSettings: Boolean = true, + // This will use the default values from the capabilities + val preferredInitialScanSettings: CommonScanSettings = CommonScanSettingsEditor( + INITIAL_SCAN_SETTINGS_AVAILABLE_OPTIONS_CAPS + ).build() ) diff --git a/core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/db/migrations/RoomMigrationVersion7To8.kt b/core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/db/migrations/RoomMigrationVersion7To8.kt new file mode 100644 index 00000000..f02f8c39 --- /dev/null +++ b/core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/db/migrations/RoomMigrationVersion7To8.kt @@ -0,0 +1,23 @@ +package io.github.chrisimx.scanbridge.db.migrations + +import androidx.room.migration.Migration +import androidx.sqlite.SQLiteConnection +import io.github.chrisimx.scanbridge.ScanSettingsJson +import io.github.chrisimx.scanbridge.db.entities.AppSettings + +val ROOM_MIGRATION_7_8 = object : Migration(7, 8) { + override fun migrate(connection: SQLiteConnection) { + // Entity: AppSettings + // + preferredInitialScanSettings: CommonScanSettings + val defaultAppSettings = AppSettings() + val defaultPreferredInitialScanSettings = defaultAppSettings.preferredInitialScanSettings + + val serializedDefaultPreferredInitialScanSettings = ScanSettingsJson.json + .encodeToString(defaultPreferredInitialScanSettings) + + val statement = connection.prepare("ALTER TABLE appsettings ADD COLUMN preferredInitialScanSettings TEXT NOT NULL DEFAULT ?") + statement.bindText(1, serializedDefaultPreferredInitialScanSettings) + statement.step() + statement.close() + } +} diff --git a/core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/initialscansettings/DefaultInitialScanSettingsProvider.kt b/core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/initialscansettings/DefaultInitialScanSettingsProvider.kt new file mode 100644 index 00000000..66a7ffb4 --- /dev/null +++ b/core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/initialscansettings/DefaultInitialScanSettingsProvider.kt @@ -0,0 +1,13 @@ +package io.github.chrisimx.scanbridge.initialscansettings + +import io.github.chrisimx.anyscan.CommonScanSettingsEditor +import io.github.chrisimx.anyscan.CommonScannerCapabilities +import io.github.chrisimx.scanbridge.appsettings.AppSettingsRepository + +class DefaultInitialScanSettingsProvider(val appSettingsRepos: AppSettingsRepository) : InitialScanSettingsProvider { + override suspend fun applyDefaults(editor: CommonScanSettingsEditor, capabilities: CommonScannerCapabilities) { + val preferredInitialScanSettings = appSettingsRepos.getPreferredInitialScanSettings() + + editor.setSettings(preferredInitialScanSettings) + } +} diff --git a/core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/initialscansettings/InitialScanSettingsAvailableOptions.kt b/core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/initialscansettings/InitialScanSettingsAvailableOptions.kt new file mode 100644 index 00000000..ae1fee98 --- /dev/null +++ b/core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/initialscansettings/InitialScanSettingsAvailableOptions.kt @@ -0,0 +1,84 @@ +package io.github.chrisimx.scanbridge.initialscansettings + +import io.github.chrisimx.anyscan.Area +import io.github.chrisimx.anyscan.Choice +import io.github.chrisimx.anyscan.ColorMode +import io.github.chrisimx.anyscan.CommonInputSourceCaps +import io.github.chrisimx.anyscan.CommonInputSourceType +import io.github.chrisimx.anyscan.CommonScannerCapabilities +import io.github.chrisimx.anyscan.DiscreteResolution +import io.github.chrisimx.anyscan.DiscreteResolutionValue +import io.github.chrisimx.anyscan.FileFormat +import io.github.chrisimx.anyscan.MutableScannerCapabilityMap +import io.github.chrisimx.anyscan.ScanIntent +import io.github.chrisimx.anyscan.ScanRegionValue +import io.github.chrisimx.anyscan.ScanSettingParam +import io.github.chrisimx.anyscan.ScannerCapabilityMap +import io.github.chrisimx.anyscan.ScannerConcept +import io.github.chrisimx.anyscan.StringValue +import io.github.chrisimx.anyscan.inches +import io.github.chrisimx.anyscan.millimeters +import io.github.chrisimx.enumorrawcodegen.asEnumOrRaw + +/** + * A fake scanner capability that provides options for the user when configuring their preferred initial scan settings + */ +val INITIAL_SCAN_SETTINGS_AVAILABLE_OPTIONS_CAPS = CommonScannerCapabilities( + null, + listOf( + fakeInputSourceCaps(CommonInputSourceType.PLATEN), + fakeInputSourceCaps(CommonInputSourceType.ADF_SIMPLEX), + fakeInputSourceCaps(CommonInputSourceType.ADF_DUPLEX) + ) +) + +private fun fakeInputSourceCaps(inputSourceType: CommonInputSourceType): CommonInputSourceCaps = CommonInputSourceCaps( + listOf(FileFormat.JPEG.asEnumOrRaw(), FileFormat.PDF.asEnumOrRaw()), + inputSourceType, + fakeScannerCapabilitiesMap() +) + +private fun fakeScannerCapabilitiesMap(): ScannerCapabilityMap { + val scannerCapabilitiesMap = MutableScannerCapabilityMap() + + val scanRegionParam = ScanSettingParam.ScanSettingRegionParam( + ScannerConcept.ScanRegion, + ScanRegionValue(Area(297.millimeters(), 210.millimeters())), + ScanRegionValue(Area(0.millimeters(), 0.millimeters())), + ScanRegionValue(Area(200000.inches(), 200000.inches())) + ) + + val resolutionParam = ScanSettingParam.ScanSettingChoiceParam( + ScannerConcept.ScanResolution, + DiscreteResolutionValue(DiscreteResolution(600u, 600u)), + listOf( + 100u, + 200u, + 300u, + 400u, + 500u, + 600u, + 1200u, + 2400u + ).map { Choice(DiscreteResolutionValue(DiscreteResolution(it, it))) } + ) + + val colorModeParam = ScanSettingParam.ScanSettingChoiceParam( + ScannerConcept.ColorMode, + StringValue(ColorMode.RGB24.name), + ColorMode.entries.map { Choice(StringValue(it.name)) } + ) + + val scanIntentParam = ScanSettingParam.ScanSettingChoiceParam( + ScannerConcept.ScanIntent, + StringValue(ScanIntent.Document.name), + ScanIntent.entries.map { Choice(StringValue(it.name)) } + ) + + scannerCapabilitiesMap += scanRegionParam + scannerCapabilitiesMap += resolutionParam + scannerCapabilitiesMap += colorModeParam + scannerCapabilitiesMap += scanIntentParam + + return ScannerCapabilityMap.fromMutable(scannerCapabilitiesMap) +} diff --git a/core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/ports/InitialScanSettingsProvider.kt b/core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/initialscansettings/InitialScanSettingsProvider.kt similarity index 50% rename from core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/ports/InitialScanSettingsProvider.kt rename to core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/initialscansettings/InitialScanSettingsProvider.kt index 98a47d93..344893f9 100644 --- a/core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/ports/InitialScanSettingsProvider.kt +++ b/core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/initialscansettings/InitialScanSettingsProvider.kt @@ -1,8 +1,8 @@ -package io.github.chrisimx.scanbridge.ports +package io.github.chrisimx.scanbridge.initialscansettings import io.github.chrisimx.anyscan.CommonScanSettingsEditor import io.github.chrisimx.anyscan.CommonScannerCapabilities interface InitialScanSettingsProvider { - fun applyDefaults(editor: CommonScanSettingsEditor, capabilities: CommonScannerCapabilities) + suspend fun applyDefaults(editor: CommonScanSettingsEditor, capabilities: CommonScannerCapabilities) } diff --git a/core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/localization/NumberFormatter.kt b/core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/localization/NumberFormatter.kt new file mode 100644 index 00000000..3dc25d56 --- /dev/null +++ b/core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/localization/NumberFormatter.kt @@ -0,0 +1,10 @@ +package io.github.chrisimx.scanbridge.localization + +interface NumberFormatter { + fun parseDouble(value: String): Double? + fun formatDouble(value: Double): String +} + +fun String.parseDouble(formatter: NumberFormatter): Double? = formatter.parseDouble(this) + +fun Double.formatDouble(formatter: NumberFormatter): String = formatter.formatDouble(this) diff --git a/core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/model/ScanSettingsEnterableData.kt b/core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/model/ScanSettingsEnterableData.kt index f2a4ce8f..e9101065 100644 --- a/core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/model/ScanSettingsEnterableData.kt +++ b/core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/model/ScanSettingsEnterableData.kt @@ -27,5 +27,5 @@ data class ScanSettingsEnterableDataV1( val customMenuEnabled: Boolean = false, val widthString: String = "", val heightString: String = "", - val maximumSize: Boolean = true + val maximumSize: Boolean = false ) diff --git a/androidApp/src/main/java/io/github/chrisimx/scanbridge/util/UIInputSourceType.kt b/core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/model/UIInputSourceType.kt similarity index 89% rename from androidApp/src/main/java/io/github/chrisimx/scanbridge/util/UIInputSourceType.kt rename to core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/model/UIInputSourceType.kt index 54b03a4a..5a17a46b 100644 --- a/androidApp/src/main/java/io/github/chrisimx/scanbridge/util/UIInputSourceType.kt +++ b/core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/model/UIInputSourceType.kt @@ -1,4 +1,4 @@ -package io.github.chrisimx.scanbridge.util +package io.github.chrisimx.scanbridge.model import io.github.chrisimx.anyscan.CommonInputSourceType diff --git a/androidApp/src/main/java/io/github/chrisimx/scanbridge/util/StateFlowExtensions.kt b/core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/util/StateFlowExtensions.kt similarity index 100% rename from androidApp/src/main/java/io/github/chrisimx/scanbridge/util/StateFlowExtensions.kt rename to core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/util/StateFlowExtensions.kt diff --git a/core/src/jvmAndAndroid/kotlin/io/github/chrisimx/localization/JvmNumberFormatter.kt b/core/src/jvmAndAndroid/kotlin/io/github/chrisimx/localization/JvmNumberFormatter.kt new file mode 100644 index 00000000..c1e46150 --- /dev/null +++ b/core/src/jvmAndAndroid/kotlin/io/github/chrisimx/localization/JvmNumberFormatter.kt @@ -0,0 +1,12 @@ +package io.github.chrisimx.localization + +import io.github.chrisimx.scanbridge.localization.NumberFormatter +import java.text.NumberFormat + +class JvmNumberFormatter : NumberFormatter { + override fun parseDouble(value: String): Double? = runCatching { + NumberFormat.getInstance().parse(value).toDouble() + }.getOrNull() + + override fun formatDouble(value: Double): String = NumberFormat.getInstance().format(value) +} diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 5872cf2c..ebe9aade 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -9,7 +9,7 @@ concurrentFutures = "1.2.0" constraintlayoutCompose = "1.1.1" converterGson = "2.9.0" datastore = "1.2.0" -anyscankt = "2.1.3" +anyscankt = "2.1.7" escl-mock-server = "1.0.1" itextCore = "9.3.0" kotlin = "2.3.20-Beta1"