diff --git a/.github/workflows/code_review.yml b/.github/workflows/code_review.yml index b0c107e..04f2e7c 100644 --- a/.github/workflows/code_review.yml +++ b/.github/workflows/code_review.yml @@ -9,10 +9,12 @@ on: push: branches: - main + - dev pull_request: types: [ opened, reopened, labeled, unlabeled, ready_for_review, synchronize ] branches: - main + - dev jobs: SetUp: diff --git a/composeApp/build.gradle.kts b/composeApp/build.gradle.kts index 05cbd2c..4dad117 100644 --- a/composeApp/build.gradle.kts +++ b/composeApp/build.gradle.kts @@ -1,6 +1,6 @@ import org.jetbrains.kotlin.gradle.ExperimentalKotlinGradlePluginApi +import org.jetbrains.kotlin.gradle.ExperimentalWasmDsl import org.jetbrains.kotlin.gradle.dsl.JvmTarget -import org.jetbrains.kotlin.gradle.targets.js.dsl.ExperimentalWasmDsl import org.jetbrains.kotlin.gradle.targets.js.webpack.KotlinWebpackConfig plugins { @@ -8,6 +8,7 @@ plugins { alias(libs.plugins.androidApplication) alias(libs.plugins.jetbrainsCompose) alias(libs.plugins.compose.compiler) + alias(libs.plugins.serialization) } kotlin { @@ -59,12 +60,12 @@ kotlin { androidMain.dependencies { implementation(compose.preview) implementation(libs.androidx.activity.compose) + implementation(libs.androidx.preference) implementation(libs.koin.android) } commonMain.dependencies { implementation(compose.runtime) implementation(compose.foundation) - implementation(compose.material) implementation(compose.material3) implementation(compose.materialIconsExtended) implementation(compose.ui) @@ -72,11 +73,16 @@ kotlin { implementation(compose.components.uiToolingPreview) implementation(libs.androidx.navigation.compose) implementation(libs.androidx.viewmodel.compose) + implementation(libs.androidx.runtime.compose) implementation(libs.koin.core) implementation(libs.koin.compose) implementation(libs.koin.compose.viewmodel) implementation(libs.kotlinx.coroutines.core) implementation(libs.kotlinx.datetime) + implementation(libs.kotlinx.serialization) + implementation(libs.settings.multiplatform) + implementation(libs.settings.multiplatform.serialization) + implementation(libs.settings.multiplatform.coroutines) } commonTest.dependencies { implementation(kotlin("test")) diff --git a/composeApp/src/androidMain/kotlin/org/noiseplanet/noisecapture/PlatformModule.kt b/composeApp/src/androidMain/kotlin/org/noiseplanet/noisecapture/PlatformModule.kt index 4146b55..32ad4d3 100644 --- a/composeApp/src/androidMain/kotlin/org/noiseplanet/noisecapture/PlatformModule.kt +++ b/composeApp/src/androidMain/kotlin/org/noiseplanet/noisecapture/PlatformModule.kt @@ -1,7 +1,9 @@ package org.noiseplanet.noisecapture import AndroidLogger - +import androidx.preference.PreferenceManager +import com.russhwolf.settings.Settings +import com.russhwolf.settings.SharedPreferencesSettings import org.koin.core.module.Module import org.koin.core.parameter.parametersOf import org.koin.dsl.module @@ -24,4 +26,9 @@ val platformModule: Module = module { parametersOf("AudioSource") }) } + + single { + val sharedPreferences = PreferenceManager.getDefaultSharedPreferences(get()) + SharedPreferencesSettings(sharedPreferences) + } } diff --git a/composeApp/src/androidMain/kotlin/org/noiseplanet/noisecapture/util/shadow/BlurMaskFilter.android.kt b/composeApp/src/androidMain/kotlin/org/noiseplanet/noisecapture/util/shadow/BlurMaskFilter.android.kt new file mode 100644 index 0000000..a68b956 --- /dev/null +++ b/composeApp/src/androidMain/kotlin/org/noiseplanet/noisecapture/util/shadow/BlurMaskFilter.android.kt @@ -0,0 +1,9 @@ +package org.noiseplanet.noisecapture.util.shadow + +import android.graphics.BlurMaskFilter +import androidx.compose.ui.graphics.NativePaint + + +actual fun NativePaint.setBlurMaskFilter(blurRadius: Float) { + this.maskFilter = BlurMaskFilter(blurRadius, BlurMaskFilter.Blur.NORMAL) +} diff --git a/composeApp/src/androidUnitTest/kotlin/IgnoreUtil.android.kt b/composeApp/src/androidUnitTest/kotlin/IgnoreUtil.android.kt index f5a2154..067556d 100644 --- a/composeApp/src/androidUnitTest/kotlin/IgnoreUtil.android.kt +++ b/composeApp/src/androidUnitTest/kotlin/IgnoreUtil.android.kt @@ -1,3 +1,5 @@ +@file:Suppress("EXPECT_ACTUAL_CLASSIFIERS_ARE_IN_BETA_WARNING") + import org.junit.Ignore /** diff --git a/composeApp/src/commonMain/composeResources/values/strings.xml b/composeApp/src/commonMain/composeResources/values/strings.xml index b204311..59080ee 100644 --- a/composeApp/src/commonMain/composeResources/values/strings.xml +++ b/composeApp/src/commonMain/composeResources/values/strings.xml @@ -11,9 +11,6 @@ Measurement feedback Measurements statistics Last measurement map - Help - About - Calibration Settings @@ -26,4 +23,92 @@ New measurement + + + + Settings + + USER PROFILE + Acoustics knowledge + How would you rank your knowledge about acoustics and sonic environments? + + GENERAL + Tooltips + Show tooltips across the NoiseCapture app + Disclaimer + Show a usage disclaimer upon opening the app + Notification + Show a notification with a link to the Noise-Planet website (TODO: When is this notification shown?) + Automatic transfer + Transfer measurements automatically after completion + Wi-Fi only + Only transfer measurements data over Wi-Fi to reduce mobile data consumption + + MEASUREMENTS + Windowing mode + Real time measurement windowing mode (doesn't affect the end result) + Limit duration + Limit measurements duration to a certain length + Max duration (in seconds) + Measurements will automatically stop after this amount of time has elapsed + Spectrogram mode + Set the spectrogram scale mode (logarithmic or linear) + + CALIBRATION + Signal gain correction (dB) + Adds a gain correction to the input signal after calibration + Countdown duration (seconds) + How many seconds will elapse before the calibration starts + Calibration duration (seconds) + Sets the duration of the calibration in seconds + Signal output + Pick the audio output used for the linearity test + + MAP + Shown measurements count + Adjust the maximum number of measurements shown on the map at once (set this to 0 to show all measurements) + + + + Beginner + I know nothing or only a few things about noise + Confirmed + I have some knowledge about noise + Expert + I am an expert or professional in the domain of noise + + + + Linear + Linear scale + Log + Logarithmic scale + + + + Phone call + Phone call + System sound + System sound + Ringtone + Ringtone + Music + Music + Alarm + Alarm + Notification + Notification + DMTF + Dual tone multi-frequency (DMTF) + + + + Hann + Hann + Rect + Rectangular + + + + N/A diff --git a/composeApp/src/commonMain/kotlin/App.kt b/composeApp/src/commonMain/kotlin/App.kt index 67f06ba..2253c6e 100644 --- a/composeApp/src/commonMain/kotlin/App.kt +++ b/composeApp/src/commonMain/kotlin/App.kt @@ -1,4 +1,4 @@ -import androidx.compose.material.MaterialTheme +import androidx.compose.material3.MaterialTheme import androidx.compose.runtime.Composable import org.jetbrains.compose.ui.tooling.preview.Preview import org.koin.compose.KoinContext diff --git a/composeApp/src/commonMain/kotlin/org/noiseplanet/noisecapture/Koin.kt b/composeApp/src/commonMain/kotlin/org/noiseplanet/noisecapture/Koin.kt index d2381ca..95f25ee 100644 --- a/composeApp/src/commonMain/kotlin/org/noiseplanet/noisecapture/Koin.kt +++ b/composeApp/src/commonMain/kotlin/org/noiseplanet/noisecapture/Koin.kt @@ -2,15 +2,16 @@ package org.noiseplanet.noisecapture import org.koin.core.KoinApplication import org.koin.core.context.startKoin +import org.koin.core.context.stopKoin import org.koin.core.module.Module import org.koin.dsl.module -import org.noiseplanet.noisecapture.measurements.DefaultMeasurementService -import org.noiseplanet.noisecapture.measurements.MeasurementsService import org.noiseplanet.noisecapture.permission.defaultPermissionModule import org.noiseplanet.noisecapture.permission.platformPermissionModule +import org.noiseplanet.noisecapture.services.servicesModule import org.noiseplanet.noisecapture.ui.features.home.homeModule import org.noiseplanet.noisecapture.ui.features.measurement.measurementModule import org.noiseplanet.noisecapture.ui.features.permission.requestPermissionModule +import org.noiseplanet.noisecapture.ui.features.settings.settingsModule /** * Create root Koin application and register modules shared between platforms @@ -18,17 +19,16 @@ import org.noiseplanet.noisecapture.ui.features.permission.requestPermissionModu fun initKoin( additionalModules: List = emptyList(), ): KoinApplication { + + stopKoin() + return startKoin { modules( module { includes(additionalModules) }, - module { - single { - DefaultMeasurementService(audioSource = get(), logger = get()) - } - }, + servicesModule, defaultPermissionModule, platformPermissionModule(), @@ -36,6 +36,7 @@ fun initKoin( homeModule, requestPermissionModule, measurementModule, + settingsModule, ) createEagerInstances() } diff --git a/composeApp/src/commonMain/kotlin/org/noiseplanet/noisecapture/NoiseCaptureApp.kt b/composeApp/src/commonMain/kotlin/org/noiseplanet/noisecapture/NoiseCaptureApp.kt index 7fa41a9..04babf1 100644 --- a/composeApp/src/commonMain/kotlin/org/noiseplanet/noisecapture/NoiseCaptureApp.kt +++ b/composeApp/src/commonMain/kotlin/org/noiseplanet/noisecapture/NoiseCaptureApp.kt @@ -5,7 +5,7 @@ import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.navigationBars import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.windowInsetsPadding -import androidx.compose.material.Scaffold +import androidx.compose.material3.Scaffold import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.ui.Modifier @@ -18,6 +18,7 @@ import org.noiseplanet.noisecapture.ui.AppBar import org.noiseplanet.noisecapture.ui.features.home.HomeScreen import org.noiseplanet.noisecapture.ui.features.measurement.MeasurementScreen import org.noiseplanet.noisecapture.ui.features.permission.RequestPermissionScreen +import org.noiseplanet.noisecapture.ui.features.settings.SettingsScreen import org.noiseplanet.noisecapture.ui.navigation.Route import org.noiseplanet.noisecapture.ui.navigation.Transitions @@ -59,19 +60,24 @@ fun NoiseCaptureApp() { .windowInsetsPadding(WindowInsets.navigationBars) ) { composable(route = Route.Home.name) { - // TODO: Silently check for permissions and bypass this step if they are already all granted HomeScreen(navigationController = navController) } + composable(route = Route.RequestPermission.name) { + // TODO: Silently check for permissions and bypass this step if + // they are already all granted RequestPermissionScreen( onClickNextButton = { navController.navigate(Route.Measurement.name) } ) } + composable(route = Route.Measurement.name) { MeasurementScreen() } + + composable(route = Route.Settings.name) { SettingsScreen() } } } } diff --git a/composeApp/src/commonMain/kotlin/org/noiseplanet/noisecapture/model/AcousticsKnowledgeLevel.kt b/composeApp/src/commonMain/kotlin/org/noiseplanet/noisecapture/model/AcousticsKnowledgeLevel.kt new file mode 100644 index 0000000..3844c42 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/org/noiseplanet/noisecapture/model/AcousticsKnowledgeLevel.kt @@ -0,0 +1,38 @@ +package org.noiseplanet.noisecapture.model + +import kotlinx.serialization.Serializable +import noisecapture.composeapp.generated.resources.Res +import noisecapture.composeapp.generated.resources.acoustics_knowledge_beginner_description +import noisecapture.composeapp.generated.resources.acoustics_knowledge_beginner_title +import noisecapture.composeapp.generated.resources.acoustics_knowledge_confirmed_description +import noisecapture.composeapp.generated.resources.acoustics_knowledge_confirmed_title +import noisecapture.composeapp.generated.resources.acoustics_knowledge_expert_description +import noisecapture.composeapp.generated.resources.acoustics_knowledge_expert_title +import org.jetbrains.compose.resources.StringResource +import org.noiseplanet.noisecapture.util.IterableEnum +import org.noiseplanet.noisecapture.util.ShortNameRepresentable +import kotlin.enums.EnumEntries + +@Serializable +enum class AcousticsKnowledgeLevel : IterableEnum, ShortNameRepresentable { + + BEGINNER { + + override val fullName: StringResource = Res.string.acoustics_knowledge_beginner_description + override val shortName: StringResource = Res.string.acoustics_knowledge_beginner_title + }, + + CONFIRMED { + + override val fullName: StringResource = Res.string.acoustics_knowledge_confirmed_description + override val shortName: StringResource = Res.string.acoustics_knowledge_confirmed_title + }, + + EXPERT { + + override val fullName: StringResource = Res.string.acoustics_knowledge_expert_description + override val shortName: StringResource = Res.string.acoustics_knowledge_expert_title + }; + + override fun entries(): EnumEntries = entries +} diff --git a/composeApp/src/commonMain/kotlin/org/noiseplanet/noisecapture/model/CalibrationTestAudioOutput.kt b/composeApp/src/commonMain/kotlin/org/noiseplanet/noisecapture/model/CalibrationTestAudioOutput.kt new file mode 100644 index 0000000..b2aa5ac --- /dev/null +++ b/composeApp/src/commonMain/kotlin/org/noiseplanet/noisecapture/model/CalibrationTestAudioOutput.kt @@ -0,0 +1,85 @@ +package org.noiseplanet.noisecapture.model + +import kotlinx.serialization.Serializable +import noisecapture.composeapp.generated.resources.Res +import noisecapture.composeapp.generated.resources.calibration_output_alarm_description +import noisecapture.composeapp.generated.resources.calibration_output_alarm_title +import noisecapture.composeapp.generated.resources.calibration_output_dmtf_description +import noisecapture.composeapp.generated.resources.calibration_output_dmtf_title +import noisecapture.composeapp.generated.resources.calibration_output_music_description +import noisecapture.composeapp.generated.resources.calibration_output_music_title +import noisecapture.composeapp.generated.resources.calibration_output_notification_description +import noisecapture.composeapp.generated.resources.calibration_output_notification_title +import noisecapture.composeapp.generated.resources.calibration_output_phonecall_description +import noisecapture.composeapp.generated.resources.calibration_output_phonecall_title +import noisecapture.composeapp.generated.resources.calibration_output_ringtone_description +import noisecapture.composeapp.generated.resources.calibration_output_ringtone_title +import noisecapture.composeapp.generated.resources.calibration_output_system_sound_description +import noisecapture.composeapp.generated.resources.calibration_output_system_sound_title +import org.jetbrains.compose.resources.StringResource +import org.noiseplanet.noisecapture.util.IterableEnum +import org.noiseplanet.noisecapture.util.ShortNameRepresentable +import kotlin.enums.EnumEntries + +@Serializable +enum class CalibrationTestAudioOutput : IterableEnum, + ShortNameRepresentable { + + PHONE_CALL { + + override val shortName: StringResource = + Res.string.calibration_output_phonecall_title + override val fullName: StringResource = + Res.string.calibration_output_phonecall_description + }, + + SYSTEM_SOUND { + + override val shortName: StringResource = + Res.string.calibration_output_system_sound_title + override val fullName: StringResource = + Res.string.calibration_output_system_sound_description + }, + + RINGTONE { + + override val shortName: StringResource = + Res.string.calibration_output_ringtone_title + override val fullName: StringResource = + Res.string.calibration_output_ringtone_description + }, + + MUSIC { + + override val shortName: StringResource = + Res.string.calibration_output_music_title + override val fullName: StringResource = + Res.string.calibration_output_music_description + }, + + ALARM { + + override val shortName: StringResource = + Res.string.calibration_output_alarm_title + override val fullName: StringResource = + Res.string.calibration_output_alarm_description + }, + + NOTIFICATION { + + override val shortName: StringResource = + Res.string.calibration_output_notification_title + override val fullName: StringResource = + Res.string.calibration_output_notification_description + }, + + DMTF { + + override val shortName: StringResource = + Res.string.calibration_output_dmtf_title + override val fullName: StringResource = + Res.string.calibration_output_dmtf_description + }; + + override fun entries(): EnumEntries = entries +} diff --git a/composeApp/src/commonMain/kotlin/org/noiseplanet/noisecapture/model/MeasurementWindowingMode.kt b/composeApp/src/commonMain/kotlin/org/noiseplanet/noisecapture/model/MeasurementWindowingMode.kt new file mode 100644 index 0000000..5a45c9c --- /dev/null +++ b/composeApp/src/commonMain/kotlin/org/noiseplanet/noisecapture/model/MeasurementWindowingMode.kt @@ -0,0 +1,35 @@ +package org.noiseplanet.noisecapture.model + +import kotlinx.serialization.Serializable +import noisecapture.composeapp.generated.resources.Res +import noisecapture.composeapp.generated.resources.measurement_windowing_mode_hann_description +import noisecapture.composeapp.generated.resources.measurement_windowing_mode_hann_title +import noisecapture.composeapp.generated.resources.measurement_windowing_mode_rect_description +import noisecapture.composeapp.generated.resources.measurement_windowing_mode_rect_title +import org.jetbrains.compose.resources.StringResource +import org.noiseplanet.noisecapture.util.IterableEnum +import org.noiseplanet.noisecapture.util.ShortNameRepresentable +import kotlin.enums.EnumEntries + +@Serializable +enum class MeasurementWindowingMode : IterableEnum, + ShortNameRepresentable { + + HANN { + + override val fullName: StringResource = + Res.string.measurement_windowing_mode_hann_description + override val shortName: StringResource = + Res.string.measurement_windowing_mode_hann_title + }, + + RECTANGULAR { + + override val fullName: StringResource = + Res.string.measurement_windowing_mode_rect_description + override val shortName: StringResource = + Res.string.measurement_windowing_mode_rect_title + }; + + override fun entries(): EnumEntries = entries +} diff --git a/composeApp/src/commonMain/kotlin/org/noiseplanet/noisecapture/model/SpectrogramScaleMode.kt b/composeApp/src/commonMain/kotlin/org/noiseplanet/noisecapture/model/SpectrogramScaleMode.kt new file mode 100644 index 0000000..3ed7381 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/org/noiseplanet/noisecapture/model/SpectrogramScaleMode.kt @@ -0,0 +1,35 @@ +package org.noiseplanet.noisecapture.model + +import kotlinx.serialization.Serializable +import noisecapture.composeapp.generated.resources.Res +import noisecapture.composeapp.generated.resources.spectrogram_scale_mode_linear_description +import noisecapture.composeapp.generated.resources.spectrogram_scale_mode_linear_title +import noisecapture.composeapp.generated.resources.spectrogram_scale_mode_logarithmic_description +import noisecapture.composeapp.generated.resources.spectrogram_scale_mode_logarithmic_title +import org.jetbrains.compose.resources.StringResource +import org.noiseplanet.noisecapture.util.IterableEnum +import org.noiseplanet.noisecapture.util.ShortNameRepresentable +import kotlin.enums.EnumEntries + + +@Serializable +enum class SpectrogramScaleMode : IterableEnum, ShortNameRepresentable { + + SCALE_LINEAR { + + override val fullName: StringResource = + Res.string.spectrogram_scale_mode_linear_description + override val shortName: StringResource = + Res.string.spectrogram_scale_mode_linear_title + }, + + SCALE_LOG { + + override val fullName: StringResource = + Res.string.spectrogram_scale_mode_logarithmic_description + override val shortName: StringResource = + Res.string.spectrogram_scale_mode_logarithmic_title + }; + + override fun entries(): EnumEntries = entries +} diff --git a/composeApp/src/commonMain/kotlin/org/noiseplanet/noisecapture/permission/PermissionModule.kt b/composeApp/src/commonMain/kotlin/org/noiseplanet/noisecapture/permission/PermissionModule.kt index 57fb593..69dc324 100644 --- a/composeApp/src/commonMain/kotlin/org/noiseplanet/noisecapture/permission/PermissionModule.kt +++ b/composeApp/src/commonMain/kotlin/org/noiseplanet/noisecapture/permission/PermissionModule.kt @@ -14,8 +14,6 @@ internal expect fun platformPermissionModule(): Module internal val defaultPermissionModule = module { - single { DefaultPermissionService() } - for (permission in Permission.entries) { // Register a default delegate implementation for each permission that will be overridden // in each platform module depending on the supported permissions diff --git a/composeApp/src/commonMain/kotlin/org/noiseplanet/noisecapture/measurements/MeasurementsService.kt b/composeApp/src/commonMain/kotlin/org/noiseplanet/noisecapture/services/MeasurementsService.kt similarity index 99% rename from composeApp/src/commonMain/kotlin/org/noiseplanet/noisecapture/measurements/MeasurementsService.kt rename to composeApp/src/commonMain/kotlin/org/noiseplanet/noisecapture/services/MeasurementsService.kt index ce1ae6c..7a867f8 100644 --- a/composeApp/src/commonMain/kotlin/org/noiseplanet/noisecapture/measurements/MeasurementsService.kt +++ b/composeApp/src/commonMain/kotlin/org/noiseplanet/noisecapture/services/MeasurementsService.kt @@ -1,4 +1,4 @@ -package org.noiseplanet.noisecapture.measurements +package org.noiseplanet.noisecapture.services import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers diff --git a/composeApp/src/commonMain/kotlin/org/noiseplanet/noisecapture/permission/PermissionService.kt b/composeApp/src/commonMain/kotlin/org/noiseplanet/noisecapture/services/PermissionService.kt similarity index 94% rename from composeApp/src/commonMain/kotlin/org/noiseplanet/noisecapture/permission/PermissionService.kt rename to composeApp/src/commonMain/kotlin/org/noiseplanet/noisecapture/services/PermissionService.kt index 28acafa..30c0e50 100644 --- a/composeApp/src/commonMain/kotlin/org/noiseplanet/noisecapture/permission/PermissionService.kt +++ b/composeApp/src/commonMain/kotlin/org/noiseplanet/noisecapture/services/PermissionService.kt @@ -1,4 +1,4 @@ -package org.noiseplanet.noisecapture.permission +package org.noiseplanet.noisecapture.services import kotlinx.coroutines.delay import kotlinx.coroutines.flow.Flow @@ -6,6 +6,8 @@ import kotlinx.coroutines.flow.flow import org.koin.core.component.KoinComponent import org.koin.core.component.get import org.koin.core.qualifier.named +import org.noiseplanet.noisecapture.permission.Permission +import org.noiseplanet.noisecapture.permission.PermissionState import org.noiseplanet.noisecapture.permission.delegate.PermissionDelegate /** diff --git a/composeApp/src/commonMain/kotlin/org/noiseplanet/noisecapture/services/ServicesModule.kt b/composeApp/src/commonMain/kotlin/org/noiseplanet/noisecapture/services/ServicesModule.kt new file mode 100644 index 0000000..593628b --- /dev/null +++ b/composeApp/src/commonMain/kotlin/org/noiseplanet/noisecapture/services/ServicesModule.kt @@ -0,0 +1,21 @@ +package org.noiseplanet.noisecapture.services + +import org.koin.dsl.module + +val servicesModule = module { + + single { DefaultPermissionService() } + + single { + DefaultMeasurementService( + audioSource = get(), + logger = get(), + ) + } + + single { + DefaultUserSettingsService( + settingsProvider = get(), + ) + } +} diff --git a/composeApp/src/commonMain/kotlin/org/noiseplanet/noisecapture/services/SettingsKey.kt b/composeApp/src/commonMain/kotlin/org/noiseplanet/noisecapture/services/SettingsKey.kt new file mode 100644 index 0000000..f98e8ff --- /dev/null +++ b/composeApp/src/commonMain/kotlin/org/noiseplanet/noisecapture/services/SettingsKey.kt @@ -0,0 +1,97 @@ +package org.noiseplanet.noisecapture.services + +import kotlinx.serialization.KSerializer +import kotlinx.serialization.builtins.serializer +import org.noiseplanet.noisecapture.model.AcousticsKnowledgeLevel +import org.noiseplanet.noisecapture.model.CalibrationTestAudioOutput +import org.noiseplanet.noisecapture.model.MeasurementWindowingMode +import org.noiseplanet.noisecapture.model.SpectrogramScaleMode + +/** + * User settings keys. Each value must be serializable. + * + * For now [defaultValue] is enforced. If we need nullable setting values in the future + * we may need to make it optional. + */ +sealed class SettingsKey(val serializer: KSerializer, val defaultValue: T) { + + // User profile + data object SettingUserAcousticsKnowledge : SettingsKey( + AcousticsKnowledgeLevel.serializer(), + defaultValue = AcousticsKnowledgeLevel.BEGINNER, + ) + + // General + data object SettingTooltipsEnabled : SettingsKey( + Boolean.serializer(), + defaultValue = true, + ) + + data object SettingDisclaimersEnabled : SettingsKey( + Boolean.serializer(), + defaultValue = true, + ) + + data object SettingNotificationEnabled : SettingsKey( + Boolean.serializer(), + defaultValue = true, + ) + + data object SettingAutomaticTransferEnabled : SettingsKey( + Boolean.serializer(), + defaultValue = true, + ) + + data object SettingTransferOverWifiOnly : SettingsKey( + Boolean.serializer(), + defaultValue = true, + ) + + // Measurements + data object SettingWindowingMode : SettingsKey( + MeasurementWindowingMode.serializer(), + defaultValue = MeasurementWindowingMode.HANN, + ) + + data object SettingLimitMeasurementDuration : SettingsKey( + Boolean.serializer(), + defaultValue = false, + ) + + data object SettingMaxMeasurementDuration : SettingsKey( + Int.serializer(), + defaultValue = 30, + ) + + data object SettingSpectrogramScaleMode : SettingsKey( + SpectrogramScaleMode.serializer(), + defaultValue = SpectrogramScaleMode.SCALE_LOG, + ) + + // Calibration + data object SettingSignalGainCorrection : SettingsKey( + Double.serializer(), + defaultValue = 0.0, + ) + + data object SettingCalibrationCountdown : SettingsKey( + Int.serializer(), + defaultValue = 4, + ) + + data object SettingCalibrationDuration : SettingsKey( + Int.serializer(), + defaultValue = 6, + ) + + data object SettingTestSignalAudioOutput : SettingsKey( + CalibrationTestAudioOutput.serializer(), + defaultValue = CalibrationTestAudioOutput.PHONE_CALL, + ) + + // Map + data object SettingMapMaxMeasurementsCount : SettingsKey( + Int.serializer(), + defaultValue = 500, + ) +} diff --git a/composeApp/src/commonMain/kotlin/org/noiseplanet/noisecapture/services/UserSettingsService.kt b/composeApp/src/commonMain/kotlin/org/noiseplanet/noisecapture/services/UserSettingsService.kt new file mode 100644 index 0000000..3f736e8 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/org/noiseplanet/noisecapture/services/UserSettingsService.kt @@ -0,0 +1,94 @@ +package org.noiseplanet.noisecapture.services + +import com.russhwolf.settings.ExperimentalSettingsApi +import com.russhwolf.settings.Settings +import com.russhwolf.settings.serialization.decodeValueOrNull +import com.russhwolf.settings.serialization.encodeValue +import com.russhwolf.settings.serialization.removeValue +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.map +import kotlinx.serialization.ExperimentalSerializationApi + + +/** + * Read and write persistent user settings as key value pairs. + */ +interface UserSettingsService { + + /** + * Sets the value associated to the given key. + * + * @param key User settings [SettingsKey] + * @param value New value + */ + fun set(key: SettingsKey, value: T?) + + /** + * Gets the value associated to a given key, or [SettingsKey.defaultValue] if value is not set. + * + * @param key User settings [SettingsKey] + * @return Value or [SettingsKey.defaultValue] if not found + */ + fun get(key: SettingsKey): T + + /** + * Gets a flow of values associated to a given key, starting with [SettingsKey.defaultValue] + * if value is not set. + * + * @param key User settings [SettingsKey] + * @return Value or [SettingsKey.defaultValue] if not found + */ + fun getFlow(key: SettingsKey): Flow +} + + +/** + * Default [UserSettingsService] implementation relying on a platform specific settings provider + * + * @param settingsProvider Platform specific settings provider + */ +@OptIn(ExperimentalSerializationApi::class, ExperimentalSettingsApi::class) +class DefaultUserSettingsService( + private val settingsProvider: Settings, +) : UserSettingsService { + + /** + * Tracks changes made to settings value. + */ + private val settingsChangeListener = MutableSharedFlow(replay = 1) + + init { + // Upon initialisation, emit a first value so the flow not empty when subscribing + settingsChangeListener.tryEmit(Unit) + } + + override fun set(key: SettingsKey, value: T?) { + val keyName = requireNotNull(key::class.simpleName) { + "Could not get name from settings key" + } + + value?.let { + settingsProvider.encodeValue(key.serializer, keyName, it) + } ?: { + settingsProvider.removeValue(key.serializer, keyName) + } + // Notify that a new value was stored + settingsChangeListener.tryEmit(Unit) + } + + override fun get(key: SettingsKey): T { + val keyName = requireNotNull(key::class.simpleName) { + "Could not get name from settings key" + } + + return settingsProvider.decodeValueOrNull(key.serializer, keyName) ?: key.defaultValue + } + + override fun getFlow(key: SettingsKey): Flow { + return settingsChangeListener + .map { get(key) ?: key.defaultValue } + .distinctUntilChanged() + } +} diff --git a/composeApp/src/commonMain/kotlin/org/noiseplanet/noisecapture/ui/AppBar.kt b/composeApp/src/commonMain/kotlin/org/noiseplanet/noisecapture/ui/AppBar.kt index 17778d1..001725f 100644 --- a/composeApp/src/commonMain/kotlin/org/noiseplanet/noisecapture/ui/AppBar.kt +++ b/composeApp/src/commonMain/kotlin/org/noiseplanet/noisecapture/ui/AppBar.kt @@ -1,11 +1,11 @@ package org.noiseplanet.noisecapture.ui -import androidx.compose.material.Icon -import androidx.compose.material.IconButton -import androidx.compose.material.Text import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text import androidx.compose.material3.TopAppBar import androidx.compose.material3.TopAppBarDefaults import androidx.compose.runtime.Composable diff --git a/composeApp/src/commonMain/kotlin/org/noiseplanet/noisecapture/ui/components/CardView.kt b/composeApp/src/commonMain/kotlin/org/noiseplanet/noisecapture/ui/components/CardView.kt new file mode 100644 index 0000000..b593a89 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/org/noiseplanet/noisecapture/ui/components/CardView.kt @@ -0,0 +1,87 @@ +package org.noiseplanet.noisecapture.ui.components + +import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.animation.core.animateOffsetAsState +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.interaction.collectIsPressedAsState +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.BoxScope +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.unit.dp +import org.noiseplanet.noisecapture.util.shadow.dropShadow + + +private const val CORNER_RADIUS: Float = 10f +private const val SHADOW_OFFSET_X: Float = 0f +private const val SHADOW_OFFSET_Y: Float = 4f +private const val SHADOW_BLUR_RADIUS: Float = 12f + + +/** + * Wraps the given content into a rounded cornered card. + * Can be made clickable if given an onClick callback. Clickable cards will display a drop shadow + * that will be animated when pressed. + * + * @param backgroundColor Card's background color + * @param modifier Base modifier + * @param onClick Click listener. If provided, card will appear elevated and will animate when pressed. + * @param content Content block, passed to the internal [Box] + */ +@Composable +fun CardView( + backgroundColor: Color = Color.White, + modifier: Modifier = Modifier, + onClick: (() -> Unit)? = null, + content: @Composable BoxScope.() -> Unit, +) { + val shape = RoundedCornerShape(CORNER_RADIUS.dp) + + val interactionSource = remember { MutableInteractionSource() } + val isPressed by if (onClick != null) { + interactionSource.collectIsPressedAsState() + } else { + mutableStateOf(true) + } + + val shadowOffset by animateOffsetAsState( + targetValue = if (isPressed) { + Offset.Zero + } else { + Offset(SHADOW_OFFSET_X, SHADOW_OFFSET_Y) + } + ) + val shadowBlur by animateFloatAsState( + targetValue = if (isPressed) 0f else SHADOW_BLUR_RADIUS + ) + + var cardModifier = modifier + onClick?.let { + cardModifier = modifier.clickable( + interactionSource, + indication = null, + onClick = onClick + ) + } + + Box( + modifier = cardModifier + .dropShadow(shape, offset = shadowOffset, blur = shadowBlur) + .background(backgroundColor, shape) + .clip(shape) + .padding(16.dp), + contentAlignment = Alignment.TopStart, + content = content + ) +} diff --git a/composeApp/src/commonMain/kotlin/org/noiseplanet/noisecapture/ui/features/home/HomeModule.kt b/composeApp/src/commonMain/kotlin/org/noiseplanet/noisecapture/ui/features/home/HomeModule.kt index 48b9eb9..cfcccb9 100644 --- a/composeApp/src/commonMain/kotlin/org/noiseplanet/noisecapture/ui/features/home/HomeModule.kt +++ b/composeApp/src/commonMain/kotlin/org/noiseplanet/noisecapture/ui/features/home/HomeModule.kt @@ -2,7 +2,7 @@ package org.noiseplanet.noisecapture.ui.features.home import androidx.compose.ui.graphics.vector.ImageVector import org.jetbrains.compose.resources.StringResource -import org.koin.compose.viewmodel.dsl.viewModel +import org.koin.core.module.dsl.viewModel import org.koin.dsl.module import org.noiseplanet.noisecapture.ui.features.home.menuitem.HomeScreenViewModel import org.noiseplanet.noisecapture.ui.features.home.menuitem.MenuItemViewModel diff --git a/composeApp/src/commonMain/kotlin/org/noiseplanet/noisecapture/ui/features/home/HomeScreen.kt b/composeApp/src/commonMain/kotlin/org/noiseplanet/noisecapture/ui/features/home/HomeScreen.kt index a2c0a92..89ce5c8 100644 --- a/composeApp/src/commonMain/kotlin/org/noiseplanet/noisecapture/ui/features/home/HomeScreen.kt +++ b/composeApp/src/commonMain/kotlin/org/noiseplanet/noisecapture/ui/features/home/HomeScreen.kt @@ -1,5 +1,6 @@ package org.noiseplanet.noisecapture.ui.features.home +import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.lazy.grid.GridCells @@ -29,24 +30,26 @@ fun HomeScreen( modifier = Modifier.fillMaxSize(), color = MaterialTheme.colorScheme.background ) { - LazyVerticalGrid( - columns = GridCells.Adaptive(minSize = 96.dp), - contentPadding = PaddingValues( - start = 24.dp, - top = 24.dp, - end = 24.dp, - bottom = 24.dp - ), - content = { - items(viewModel.menuItems) { viewModel -> - MenuItem( - viewModel, - navigateTo = { route -> - navigationController.navigate(route.name) - }, - ) + Column { + LazyVerticalGrid( + columns = GridCells.Adaptive(minSize = 96.dp), + contentPadding = PaddingValues( + start = 24.dp, + top = 24.dp, + end = 24.dp, + bottom = 24.dp + ), + content = { + items(viewModel.menuItems) { viewModel -> + MenuItem( + viewModel, + navigateTo = { route -> + navigationController.navigate(route.name) + }, + ) + } } - } - ) + ) + } } } diff --git a/composeApp/src/commonMain/kotlin/org/noiseplanet/noisecapture/ui/features/home/menuitem/HomeScreenViewModel.kt b/composeApp/src/commonMain/kotlin/org/noiseplanet/noisecapture/ui/features/home/menuitem/HomeScreenViewModel.kt index 6ea2fd8..c92f7b6 100644 --- a/composeApp/src/commonMain/kotlin/org/noiseplanet/noisecapture/ui/features/home/menuitem/HomeScreenViewModel.kt +++ b/composeApp/src/commonMain/kotlin/org/noiseplanet/noisecapture/ui/features/home/menuitem/HomeScreenViewModel.kt @@ -1,21 +1,15 @@ package org.noiseplanet.noisecapture.ui.features.home.menuitem import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.automirrored.filled.Help -import androidx.compose.material.icons.filled.CenterFocusWeak import androidx.compose.material.icons.filled.History import androidx.compose.material.icons.filled.HistoryEdu -import androidx.compose.material.icons.filled.Info import androidx.compose.material.icons.filled.Map import androidx.compose.material.icons.filled.Mic import androidx.compose.material.icons.filled.Settings import androidx.compose.material.icons.filled.Timeline import androidx.lifecycle.ViewModel import noisecapture.composeapp.generated.resources.Res -import noisecapture.composeapp.generated.resources.menu_about -import noisecapture.composeapp.generated.resources.menu_calibration import noisecapture.composeapp.generated.resources.menu_feedback -import noisecapture.composeapp.generated.resources.menu_help import noisecapture.composeapp.generated.resources.menu_history import noisecapture.composeapp.generated.resources.menu_map import noisecapture.composeapp.generated.resources.menu_new_measurement @@ -45,16 +39,7 @@ class HomeScreenViewModel : ViewModel(), KoinComponent { parametersOf(Res.string.menu_map, Icons.Filled.Map, null) }, get { - parametersOf(Res.string.menu_help, Icons.AutoMirrored.Filled.Help, null) - }, - get { - parametersOf(Res.string.menu_about, Icons.Filled.Info, null) - }, - get { - parametersOf(Res.string.menu_calibration, Icons.Filled.CenterFocusWeak, null) - }, - get { - parametersOf(Res.string.menu_settings, Icons.Filled.Settings, null) + parametersOf(Res.string.menu_settings, Icons.Filled.Settings, Route.Settings) }, ) } diff --git a/composeApp/src/commonMain/kotlin/org/noiseplanet/noisecapture/ui/features/home/menuitem/MenuItem.kt b/composeApp/src/commonMain/kotlin/org/noiseplanet/noisecapture/ui/features/home/menuitem/MenuItem.kt index eda8965..e3d8c66 100644 --- a/composeApp/src/commonMain/kotlin/org/noiseplanet/noisecapture/ui/features/home/menuitem/MenuItem.kt +++ b/composeApp/src/commonMain/kotlin/org/noiseplanet/noisecapture/ui/features/home/menuitem/MenuItem.kt @@ -3,8 +3,8 @@ package org.noiseplanet.noisecapture.ui.features.home.menuitem import androidx.compose.foundation.layout.aspectRatio import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.padding -import androidx.compose.material.Icon import androidx.compose.material3.Button +import androidx.compose.material3.Icon import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp diff --git a/composeApp/src/commonMain/kotlin/org/noiseplanet/noisecapture/ui/features/measurement/MeasurementModule.kt b/composeApp/src/commonMain/kotlin/org/noiseplanet/noisecapture/ui/features/measurement/MeasurementModule.kt index 0f88b46..9f3d72b 100644 --- a/composeApp/src/commonMain/kotlin/org/noiseplanet/noisecapture/ui/features/measurement/MeasurementModule.kt +++ b/composeApp/src/commonMain/kotlin/org/noiseplanet/noisecapture/ui/features/measurement/MeasurementModule.kt @@ -1,6 +1,6 @@ package org.noiseplanet.noisecapture.ui.features.measurement -import org.koin.compose.viewmodel.dsl.viewModel +import org.koin.core.module.dsl.viewModel import org.koin.dsl.module import org.noiseplanet.noisecapture.ui.features.measurement.indicators.AcousticIndicatorsViewModel import org.noiseplanet.noisecapture.ui.features.measurement.plot.spectrogram.SpectrogramPlotViewModel diff --git a/composeApp/src/commonMain/kotlin/org/noiseplanet/noisecapture/ui/features/measurement/MeasurementScreen.kt b/composeApp/src/commonMain/kotlin/org/noiseplanet/noisecapture/ui/features/measurement/MeasurementScreen.kt index bf92e2f..f7a56fe 100644 --- a/composeApp/src/commonMain/kotlin/org/noiseplanet/noisecapture/ui/features/measurement/MeasurementScreen.kt +++ b/composeApp/src/commonMain/kotlin/org/noiseplanet/noisecapture/ui/features/measurement/MeasurementScreen.kt @@ -10,16 +10,15 @@ import androidx.compose.material3.Surface import androidx.compose.runtime.Composable import androidx.compose.runtime.DisposableEffect import androidx.compose.ui.Modifier -import androidx.compose.ui.platform.LocalLifecycleOwner import androidx.compose.ui.unit.TextUnit import androidx.compose.ui.unit.TextUnitType import androidx.compose.ui.unit.dp import androidx.lifecycle.Lifecycle import androidx.lifecycle.LifecycleEventObserver import androidx.lifecycle.LifecycleOwner +import androidx.lifecycle.compose.LocalLifecycleOwner import org.koin.compose.koinInject import org.koin.compose.viewmodel.koinViewModel -import org.koin.core.annotation.KoinExperimentalAPI import org.noiseplanet.noisecapture.ui.features.measurement.indicators.AcousticIndicatorsView const val DEFAULT_SAMPLE_RATE = 48000.0 @@ -28,7 +27,6 @@ val NOISE_LEVEL_FONT_SIZE = TextUnit(50F, TextUnitType.Sp) val SPECTRUM_PLOT_SQUARE_WIDTH = 10.dp val SPECTRUM_PLOT_SQUARE_OFFSET = 1.dp -@OptIn(KoinExperimentalAPI::class) @Composable fun MeasurementScreen( viewModel: MeasurementScreenViewModel = koinInject(), diff --git a/composeApp/src/commonMain/kotlin/org/noiseplanet/noisecapture/ui/features/measurement/MeasurementScreenViewModel.kt b/composeApp/src/commonMain/kotlin/org/noiseplanet/noisecapture/ui/features/measurement/MeasurementScreenViewModel.kt index 5819683..252e6b7 100644 --- a/composeApp/src/commonMain/kotlin/org/noiseplanet/noisecapture/ui/features/measurement/MeasurementScreenViewModel.kt +++ b/composeApp/src/commonMain/kotlin/org/noiseplanet/noisecapture/ui/features/measurement/MeasurementScreenViewModel.kt @@ -1,7 +1,7 @@ package org.noiseplanet.noisecapture.ui.features.measurement import androidx.lifecycle.ViewModel -import org.noiseplanet.noisecapture.measurements.MeasurementsService +import org.noiseplanet.noisecapture.services.MeasurementsService class MeasurementScreenViewModel( private val measurementsService: MeasurementsService, diff --git a/composeApp/src/commonMain/kotlin/org/noiseplanet/noisecapture/ui/features/measurement/indicators/AcousticIndicatorsViewModel.kt b/composeApp/src/commonMain/kotlin/org/noiseplanet/noisecapture/ui/features/measurement/indicators/AcousticIndicatorsViewModel.kt index 97bf3a3..5f9bf6c 100644 --- a/composeApp/src/commonMain/kotlin/org/noiseplanet/noisecapture/ui/features/measurement/indicators/AcousticIndicatorsViewModel.kt +++ b/composeApp/src/commonMain/kotlin/org/noiseplanet/noisecapture/ui/features/measurement/indicators/AcousticIndicatorsViewModel.kt @@ -2,7 +2,7 @@ package org.noiseplanet.noisecapture.ui.features.measurement.indicators import androidx.lifecycle.ViewModel import kotlinx.coroutines.flow.Flow -import org.noiseplanet.noisecapture.measurements.MeasurementsService +import org.noiseplanet.noisecapture.services.MeasurementsService class AcousticIndicatorsViewModel( private val measurementService: MeasurementsService, diff --git a/composeApp/src/commonMain/kotlin/org/noiseplanet/noisecapture/ui/features/measurement/plot/spectrogram/SpectrogramBitmap.kt b/composeApp/src/commonMain/kotlin/org/noiseplanet/noisecapture/ui/features/measurement/plot/spectrogram/SpectrogramBitmap.kt index ecb0d10..e608563 100644 --- a/composeApp/src/commonMain/kotlin/org/noiseplanet/noisecapture/ui/features/measurement/plot/spectrogram/SpectrogramBitmap.kt +++ b/composeApp/src/commonMain/kotlin/org/noiseplanet/noisecapture/ui/features/measurement/plot/spectrogram/SpectrogramBitmap.kt @@ -4,7 +4,8 @@ import androidx.compose.ui.graphics.ImageBitmap import androidx.compose.ui.graphics.toArgb import androidx.compose.ui.unit.IntSize import org.noiseplanet.noisecapture.audio.signal.window.SpectrumData -import org.noiseplanet.noisecapture.measurements.DefaultMeasurementService.Companion.FFT_SIZE +import org.noiseplanet.noisecapture.model.SpectrogramScaleMode +import org.noiseplanet.noisecapture.services.DefaultMeasurementService.Companion.FFT_SIZE import org.noiseplanet.noisecapture.util.toComposeColor import org.noiseplanet.noisecapture.util.toImageBitmap import org.noiseplanet.noisecapture.util.toLittleEndianBytes @@ -20,7 +21,7 @@ import kotlin.math.pow */ data class SpectrogramBitmap( val size: IntSize, - val scaleMode: ScaleMode, + val scaleMode: SpectrogramScaleMode, var offset: Int = 0, private val byteArray: ByteArray = ByteArray( bmpHeader.size + Int.SIZE_BYTES * size.width * size.height @@ -102,11 +103,6 @@ data class SpectrogramBitmap( ) } - enum class ScaleMode { - SCALE_LINEAR, - SCALE_LOG - } - private var cachedBitmap: ImageBitmap? = null private var cachedOffset: Int = -1 @@ -134,7 +130,7 @@ data class SpectrogramBitmap( val sampleRate = fftResult.sampleRate.toDouble() val hertzBySpectrumCell = sampleRate / FFT_SIZE.toDouble() val frequencyLegendPosition = when (scaleMode) { - ScaleMode.SCALE_LOG -> frequencyLegendPositionLog + SpectrogramScaleMode.SCALE_LOG -> frequencyLegendPositionLog else -> frequencyLegendPositionLinear } var lastProcessFrequencyIndex = 0 @@ -142,7 +138,7 @@ data class SpectrogramBitmap( for (pixel in 0.. SpectrogramBitmap.frequencyLegendPositionLog + SpectrogramScaleMode.SCALE_LOG -> SpectrogramBitmap.frequencyLegendPositionLog else -> SpectrogramBitmap.frequencyLegendPositionLinear } frequencyLegendPosition = frequencyLegendPosition @@ -148,7 +149,7 @@ private fun buildSpectrogramAxisBitmap( } val textSize = textMeasurer.measure(text) val tickHeightPos = when (scaleMode) { - SpectrogramBitmap.ScaleMode.SCALE_LOG -> { + SpectrogramScaleMode.SCALE_LOG -> { sheight - (log10(frequency / fMin) / ((log10(fMax / fMin) / sheight))).toInt() } diff --git a/composeApp/src/commonMain/kotlin/org/noiseplanet/noisecapture/ui/features/measurement/plot/spectrogram/SpectrogramPlotViewModel.kt b/composeApp/src/commonMain/kotlin/org/noiseplanet/noisecapture/ui/features/measurement/plot/spectrogram/SpectrogramPlotViewModel.kt index 3a4ac68..7633337 100644 --- a/composeApp/src/commonMain/kotlin/org/noiseplanet/noisecapture/ui/features/measurement/plot/spectrogram/SpectrogramPlotViewModel.kt +++ b/composeApp/src/commonMain/kotlin/org/noiseplanet/noisecapture/ui/features/measurement/plot/spectrogram/SpectrogramPlotViewModel.kt @@ -13,7 +13,8 @@ import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import org.noiseplanet.noisecapture.audio.ANDROID_GAIN import org.noiseplanet.noisecapture.log.Logger -import org.noiseplanet.noisecapture.measurements.MeasurementsService +import org.noiseplanet.noisecapture.model.SpectrogramScaleMode +import org.noiseplanet.noisecapture.services.MeasurementsService class SpectrogramPlotViewModel( private val measurementsService: MeasurementsService, @@ -33,7 +34,7 @@ class SpectrogramPlotViewModel( private var canvasSize: IntSize = IntSize.Zero private val spectrogramBitmaps = mutableStateListOf() - val scaleMode = SpectrogramBitmap.ScaleMode.SCALE_LOG + val scaleMode = SpectrogramScaleMode.SCALE_LOG val sampleRateFlow: Flow = measurementsService .getSpectrumDataFlow() diff --git a/composeApp/src/commonMain/kotlin/org/noiseplanet/noisecapture/ui/features/measurement/plot/spectrum/SpectrumPlotViewModel.kt b/composeApp/src/commonMain/kotlin/org/noiseplanet/noisecapture/ui/features/measurement/plot/spectrum/SpectrumPlotViewModel.kt index e7ac101..5b309bf 100644 --- a/composeApp/src/commonMain/kotlin/org/noiseplanet/noisecapture/ui/features/measurement/plot/spectrum/SpectrumPlotViewModel.kt +++ b/composeApp/src/commonMain/kotlin/org/noiseplanet/noisecapture/ui/features/measurement/plot/spectrum/SpectrumPlotViewModel.kt @@ -5,7 +5,7 @@ import androidx.lifecycle.ViewModel import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.map -import org.noiseplanet.noisecapture.measurements.MeasurementsService +import org.noiseplanet.noisecapture.services.MeasurementsService import org.noiseplanet.noisecapture.util.toComposeColor import kotlin.math.max diff --git a/composeApp/src/commonMain/kotlin/org/noiseplanet/noisecapture/ui/features/permission/RequestPermissionModule.kt b/composeApp/src/commonMain/kotlin/org/noiseplanet/noisecapture/ui/features/permission/RequestPermissionModule.kt index 880bd80..2d4848d 100644 --- a/composeApp/src/commonMain/kotlin/org/noiseplanet/noisecapture/ui/features/permission/RequestPermissionModule.kt +++ b/composeApp/src/commonMain/kotlin/org/noiseplanet/noisecapture/ui/features/permission/RequestPermissionModule.kt @@ -1,6 +1,6 @@ package org.noiseplanet.noisecapture.ui.features.permission -import org.koin.compose.viewmodel.dsl.viewModel +import org.koin.core.module.dsl.viewModel import org.koin.dsl.module import org.noiseplanet.noisecapture.permission.Permission import org.noiseplanet.noisecapture.ui.features.permission.stateview.PermissionStateViewModel diff --git a/composeApp/src/commonMain/kotlin/org/noiseplanet/noisecapture/ui/features/permission/RequestPermissionScreenViewModel.kt b/composeApp/src/commonMain/kotlin/org/noiseplanet/noisecapture/ui/features/permission/RequestPermissionScreenViewModel.kt index 7505d93..4bae179 100644 --- a/composeApp/src/commonMain/kotlin/org/noiseplanet/noisecapture/ui/features/permission/RequestPermissionScreenViewModel.kt +++ b/composeApp/src/commonMain/kotlin/org/noiseplanet/noisecapture/ui/features/permission/RequestPermissionScreenViewModel.kt @@ -7,8 +7,8 @@ import kotlinx.coroutines.flow.combine import org.koin.core.component.KoinComponent import org.koin.core.component.get import org.koin.core.parameter.parametersOf -import org.noiseplanet.noisecapture.permission.PermissionService import org.noiseplanet.noisecapture.permission.PermissionState +import org.noiseplanet.noisecapture.services.PermissionService import org.noiseplanet.noisecapture.ui.features.permission.stateview.PermissionStateViewModel class RequestPermissionScreenViewModel( diff --git a/composeApp/src/commonMain/kotlin/org/noiseplanet/noisecapture/ui/features/permission/stateview/PermissionStateViewModel.kt b/composeApp/src/commonMain/kotlin/org/noiseplanet/noisecapture/ui/features/permission/stateview/PermissionStateViewModel.kt index 2ac497b..f111f7a 100644 --- a/composeApp/src/commonMain/kotlin/org/noiseplanet/noisecapture/ui/features/permission/stateview/PermissionStateViewModel.kt +++ b/composeApp/src/commonMain/kotlin/org/noiseplanet/noisecapture/ui/features/permission/stateview/PermissionStateViewModel.kt @@ -14,8 +14,8 @@ import kotlinx.coroutines.flow.map import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import org.noiseplanet.noisecapture.permission.Permission -import org.noiseplanet.noisecapture.permission.PermissionService import org.noiseplanet.noisecapture.permission.PermissionState +import org.noiseplanet.noisecapture.services.PermissionService class PermissionStateViewModel( private val permission: Permission, diff --git a/composeApp/src/commonMain/kotlin/org/noiseplanet/noisecapture/ui/features/settings/SettingsModule.kt b/composeApp/src/commonMain/kotlin/org/noiseplanet/noisecapture/ui/features/settings/SettingsModule.kt new file mode 100644 index 0000000..d77c2a3 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/org/noiseplanet/noisecapture/ui/features/settings/SettingsModule.kt @@ -0,0 +1,13 @@ +package org.noiseplanet.noisecapture.ui.features.settings + +import org.koin.core.module.dsl.viewModel +import org.koin.dsl.module + +val settingsModule = module { + + viewModel { + SettingsScreenViewModel( + settingsService = get() + ) + } +} diff --git a/composeApp/src/commonMain/kotlin/org/noiseplanet/noisecapture/ui/features/settings/SettingsScreen.kt b/composeApp/src/commonMain/kotlin/org/noiseplanet/noisecapture/ui/features/settings/SettingsScreen.kt new file mode 100644 index 0000000..3522b96 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/org/noiseplanet/noisecapture/ui/features/settings/SettingsScreen.kt @@ -0,0 +1,34 @@ +package org.noiseplanet.noisecapture.ui.features.settings + +import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import org.koin.compose.koinInject +import org.noiseplanet.noisecapture.ui.features.settings.item.SettingsItem +import org.noiseplanet.noisecapture.ui.theme.listBackground + +@OptIn(ExperimentalFoundationApi::class) +@Composable +fun SettingsScreen( + viewModel: SettingsScreenViewModel = koinInject(), +) { + LazyColumn( + contentPadding = PaddingValues(start = 16.dp, end = 16.dp, bottom = 32.dp), + modifier = Modifier.background(listBackground) + ) { + viewModel.settingsItems.forEach { (sectionTitle, sectionItems) -> + stickyHeader { + SettingsSectionHeader(sectionTitle) + } + + items(sectionItems) { viewModel -> + SettingsItem(viewModel) + } + } + } +} diff --git a/composeApp/src/commonMain/kotlin/org/noiseplanet/noisecapture/ui/features/settings/SettingsScreenViewModel.kt b/composeApp/src/commonMain/kotlin/org/noiseplanet/noisecapture/ui/features/settings/SettingsScreenViewModel.kt new file mode 100644 index 0000000..80a4af2 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/org/noiseplanet/noisecapture/ui/features/settings/SettingsScreenViewModel.kt @@ -0,0 +1,174 @@ +package org.noiseplanet.noisecapture.ui.features.settings + +import androidx.lifecycle.ViewModel +import noisecapture.composeapp.generated.resources.Res +import noisecapture.composeapp.generated.resources.settings_calibration_countdown_description +import noisecapture.composeapp.generated.resources.settings_calibration_countdown_title +import noisecapture.composeapp.generated.resources.settings_calibration_duration_description +import noisecapture.composeapp.generated.resources.settings_calibration_duration_title +import noisecapture.composeapp.generated.resources.settings_calibration_gain_correction_description +import noisecapture.composeapp.generated.resources.settings_calibration_gain_correction_title +import noisecapture.composeapp.generated.resources.settings_calibration_output_description +import noisecapture.composeapp.generated.resources.settings_calibration_output_title +import noisecapture.composeapp.generated.resources.settings_general_automatic_transfer_description +import noisecapture.composeapp.generated.resources.settings_general_automatic_transfer_title +import noisecapture.composeapp.generated.resources.settings_general_disclaimer_description +import noisecapture.composeapp.generated.resources.settings_general_disclaimer_title +import noisecapture.composeapp.generated.resources.settings_general_notification_description +import noisecapture.composeapp.generated.resources.settings_general_notification_title +import noisecapture.composeapp.generated.resources.settings_general_tooltips_description +import noisecapture.composeapp.generated.resources.settings_general_tooltips_title +import noisecapture.composeapp.generated.resources.settings_general_wifi_only_description +import noisecapture.composeapp.generated.resources.settings_general_wifi_only_title +import noisecapture.composeapp.generated.resources.settings_map_measurements_count_description +import noisecapture.composeapp.generated.resources.settings_map_measurements_count_title +import noisecapture.composeapp.generated.resources.settings_measurements_limit_duration_description +import noisecapture.composeapp.generated.resources.settings_measurements_limit_duration_title +import noisecapture.composeapp.generated.resources.settings_measurements_max_duration_description +import noisecapture.composeapp.generated.resources.settings_measurements_max_duration_title +import noisecapture.composeapp.generated.resources.settings_measurements_spectrogram_mode_description +import noisecapture.composeapp.generated.resources.settings_measurements_spectrogram_mode_title +import noisecapture.composeapp.generated.resources.settings_measurements_windowing_description +import noisecapture.composeapp.generated.resources.settings_measurements_windowing_title +import noisecapture.composeapp.generated.resources.settings_section_calibration +import noisecapture.composeapp.generated.resources.settings_section_general +import noisecapture.composeapp.generated.resources.settings_section_map +import noisecapture.composeapp.generated.resources.settings_section_measurements +import noisecapture.composeapp.generated.resources.settings_section_user_profile +import noisecapture.composeapp.generated.resources.settings_user_acoustics_knowledge_description +import noisecapture.composeapp.generated.resources.settings_user_acoustics_knowledge_title +import org.jetbrains.compose.resources.StringResource +import org.noiseplanet.noisecapture.services.SettingsKey +import org.noiseplanet.noisecapture.services.UserSettingsService +import org.noiseplanet.noisecapture.ui.features.settings.item.SettingsEnumItemViewModel +import org.noiseplanet.noisecapture.ui.features.settings.item.SettingsItemViewModel + +class SettingsScreenViewModel( + private val settingsService: UserSettingsService, +) : ViewModel() { + + val settingsItems: Map>> = mapOf( + Pair( + Res.string.settings_section_user_profile, listOf( + SettingsEnumItemViewModel( + title = Res.string.settings_user_acoustics_knowledge_title, + description = Res.string.settings_user_acoustics_knowledge_description, + settingKey = SettingsKey.SettingUserAcousticsKnowledge, + settingsService = settingsService, + isFirstInSection = true, + isLastInSection = true, + ), + ) + ), + Pair( + Res.string.settings_section_general, listOf( + SettingsItemViewModel( + title = Res.string.settings_general_tooltips_title, + description = Res.string.settings_general_tooltips_description, + settingKey = SettingsKey.SettingTooltipsEnabled, + settingsService = settingsService, + isFirstInSection = true, + ), + SettingsItemViewModel( + title = Res.string.settings_general_disclaimer_title, + description = Res.string.settings_general_disclaimer_description, + settingKey = SettingsKey.SettingDisclaimersEnabled, + settingsService = settingsService, + ), + SettingsItemViewModel( + title = Res.string.settings_general_notification_title, + description = Res.string.settings_general_notification_description, + settingKey = SettingsKey.SettingNotificationEnabled, + settingsService = settingsService, + ), + SettingsItemViewModel( + title = Res.string.settings_general_automatic_transfer_title, + description = Res.string.settings_general_automatic_transfer_description, + settingKey = SettingsKey.SettingAutomaticTransferEnabled, + settingsService = settingsService, + ), + SettingsItemViewModel( + title = Res.string.settings_general_wifi_only_title, + description = Res.string.settings_general_wifi_only_description, + settingKey = SettingsKey.SettingTransferOverWifiOnly, + settingsService = settingsService, + isLastInSection = true, + isEnabled = settingsService.getFlow(SettingsKey.SettingAutomaticTransferEnabled) + ), + ) + ), + Pair( + Res.string.settings_section_measurements, listOf( + SettingsEnumItemViewModel( + title = Res.string.settings_measurements_windowing_title, + description = Res.string.settings_measurements_windowing_description, + settingKey = SettingsKey.SettingWindowingMode, + settingsService = settingsService, + isFirstInSection = true, + ), + SettingsItemViewModel( + title = Res.string.settings_measurements_limit_duration_title, + description = Res.string.settings_measurements_limit_duration_description, + settingKey = SettingsKey.SettingLimitMeasurementDuration, + settingsService = settingsService, + ), + SettingsItemViewModel( + title = Res.string.settings_measurements_max_duration_title, + description = Res.string.settings_measurements_max_duration_description, + settingKey = SettingsKey.SettingMaxMeasurementDuration, + settingsService = settingsService, + isEnabled = settingsService.getFlow(SettingsKey.SettingLimitMeasurementDuration) + ), + SettingsEnumItemViewModel( + title = Res.string.settings_measurements_spectrogram_mode_title, + description = Res.string.settings_measurements_spectrogram_mode_description, + settingKey = SettingsKey.SettingSpectrogramScaleMode, + settingsService = settingsService, + isLastInSection = true + ), + ) + ), + Pair( + Res.string.settings_section_calibration, listOf( + SettingsItemViewModel( + title = Res.string.settings_calibration_gain_correction_title, + description = Res.string.settings_calibration_gain_correction_description, + settingKey = SettingsKey.SettingSignalGainCorrection, + settingsService = settingsService, + isFirstInSection = true, + ), + SettingsItemViewModel( + title = Res.string.settings_calibration_countdown_title, + description = Res.string.settings_calibration_countdown_description, + settingKey = SettingsKey.SettingCalibrationCountdown, + settingsService = settingsService, + ), + SettingsItemViewModel( + title = Res.string.settings_calibration_duration_title, + description = Res.string.settings_calibration_duration_description, + settingKey = SettingsKey.SettingCalibrationDuration, + settingsService = settingsService, + ), + SettingsEnumItemViewModel( + title = Res.string.settings_calibration_output_title, + description = Res.string.settings_calibration_output_description, + settingKey = SettingsKey.SettingTestSignalAudioOutput, + settingsService = settingsService, + isLastInSection = true + ), + ) + ), + Pair( + Res.string.settings_section_map, listOf( + SettingsItemViewModel( + title = Res.string.settings_map_measurements_count_title, + description = Res.string.settings_map_measurements_count_description, + settingKey = SettingsKey.SettingMapMaxMeasurementsCount, + settingsService = settingsService, + isFirstInSection = true, + isLastInSection = true, + ), + ) + ) + ) +} diff --git a/composeApp/src/commonMain/kotlin/org/noiseplanet/noisecapture/ui/features/settings/SettingsSectionHeader.kt b/composeApp/src/commonMain/kotlin/org/noiseplanet/noisecapture/ui/features/settings/SettingsSectionHeader.kt new file mode 100644 index 0000000..8557228 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/org/noiseplanet/noisecapture/ui/features/settings/SettingsSectionHeader.kt @@ -0,0 +1,28 @@ +package org.noiseplanet.noisecapture.ui.features.settings + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import org.jetbrains.compose.resources.StringResource +import org.jetbrains.compose.resources.stringResource +import org.noiseplanet.noisecapture.ui.theme.listBackground + +@Composable +fun SettingsSectionHeader( + title: StringResource, +) { + Box(modifier = Modifier.fillMaxWidth().background(listBackground)) { + Text( + text = stringResource(title).uppercase(), + style = MaterialTheme.typography.titleMedium, + color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.3f), + modifier = Modifier.padding(top = 16.dp, bottom = 4.dp, start = 16.dp) + ) + } +} diff --git a/composeApp/src/commonMain/kotlin/org/noiseplanet/noisecapture/ui/features/settings/item/SettingsBooleanInput.kt b/composeApp/src/commonMain/kotlin/org/noiseplanet/noisecapture/ui/features/settings/item/SettingsBooleanInput.kt new file mode 100644 index 0000000..0b90754 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/org/noiseplanet/noisecapture/ui/features/settings/item/SettingsBooleanInput.kt @@ -0,0 +1,30 @@ +package org.noiseplanet.noisecapture.ui.features.settings.item + +import androidx.compose.material3.Switch +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.ui.Modifier + +/** + * Switch that controls a given boolean setting's value. + */ +@Composable +fun SettingsBooleanInput( + viewModel: SettingsItemViewModel, + modifier: Modifier = Modifier, +) { + val value by viewModel.getValueFlow() + .collectAsState(viewModel.getValue()) + + val isEnabled by viewModel.isEnabled.collectAsState(true) + + Switch( + checked = value, + onCheckedChange = { newValue -> + viewModel.setValue(newValue) + }, + enabled = isEnabled, + modifier = modifier + ) +} diff --git a/composeApp/src/commonMain/kotlin/org/noiseplanet/noisecapture/ui/features/settings/item/SettingsEnumInput.kt b/composeApp/src/commonMain/kotlin/org/noiseplanet/noisecapture/ui/features/settings/item/SettingsEnumInput.kt new file mode 100644 index 0000000..96f5df4 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/org/noiseplanet/noisecapture/ui/features/settings/item/SettingsEnumInput.kt @@ -0,0 +1,80 @@ +package org.noiseplanet.noisecapture.ui.features.settings.item + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.IntrinsicSize +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.width +import androidx.compose.material3.DropdownMenu +import androidx.compose.material3.DropdownMenuItem +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +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 +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import org.jetbrains.compose.resources.stringResource + +@Composable +fun SettingsEnumInput( + viewModel: SettingsEnumItemViewModel<*>, +) { + val isDropDownExpanded = remember { + mutableStateOf(false) + } + val isEnabled by viewModel.isEnabled + .collectAsState(true) + + val selectedItemName by viewModel.selected + .collectAsState(initial = viewModel.initialValue) + + Column( + modifier = Modifier.width(IntrinsicSize.Min), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center + ) { + Box { + Row( + horizontalArrangement = Arrangement.Center, + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.clickable(enabled = isEnabled) { + isDropDownExpanded.value = true + } + ) { + Text( + text = stringResource(selectedItemName), + style = MaterialTheme.typography.titleMedium, + color = MaterialTheme.colorScheme.onSurface + .copy(alpha = if (isEnabled) 1.0f else 0.5f) + ) + } + DropdownMenu( + expanded = isDropDownExpanded.value, + onDismissRequest = { + isDropDownExpanded.value = false + }, + ) { + viewModel.choices.forEachIndexed { index, name -> + DropdownMenuItem( + text = { + Text( + text = stringResource(name), + style = MaterialTheme.typography.bodyMedium + ) + }, + onClick = { + viewModel.select(index) + isDropDownExpanded.value = false + } + ) + } + } + } + + } +} diff --git a/composeApp/src/commonMain/kotlin/org/noiseplanet/noisecapture/ui/features/settings/item/SettingsItem.kt b/composeApp/src/commonMain/kotlin/org/noiseplanet/noisecapture/ui/features/settings/item/SettingsItem.kt new file mode 100644 index 0000000..bd3f6ef --- /dev/null +++ b/composeApp/src/commonMain/kotlin/org/noiseplanet/noisecapture/ui/features/settings/item/SettingsItem.kt @@ -0,0 +1,97 @@ +package org.noiseplanet.noisecapture.ui.features.settings.item + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import org.jetbrains.compose.resources.stringResource +import org.noiseplanet.noisecapture.ui.theme.listBackground +import org.noiseplanet.noisecapture.util.IterableEnum + +private const val CORNER_RADIUS: Float = 10f + +@Composable +fun SettingsItem( + viewModel: SettingsItemViewModel, +) { + val shape = RoundedCornerShape( + topStart = if (viewModel.isFirstInSection) CORNER_RADIUS.dp else 0.dp, + topEnd = if (viewModel.isFirstInSection) CORNER_RADIUS.dp else 0.dp, + bottomStart = if (viewModel.isLastInSection) CORNER_RADIUS.dp else 0.dp, + bottomEnd = if (viewModel.isLastInSection) CORNER_RADIUS.dp else 0.dp, + ) + val isEnabled by viewModel.isEnabled.collectAsState(true) + + Column( + modifier = Modifier.background(Color.White, shape) + .clip(shape) + .padding(horizontal = 16.dp), + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween, + modifier = Modifier.padding( + top = if (viewModel.isFirstInSection) 16.dp else 12.dp, + bottom = if (viewModel.isLastInSection) 16.dp else 12.dp, + ).fillMaxWidth() + ) { + Column(modifier = Modifier.weight(0.8f, fill = false)) { + Text( + stringResource(viewModel.title), + style = MaterialTheme.typography.titleMedium, + color = MaterialTheme.colorScheme.onSurface + .copy(alpha = if (isEnabled) 1.0f else 0.5f) + ) + Text( + stringResource(viewModel.description), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.3f), + maxLines = 3, + overflow = TextOverflow.Ellipsis, + ) + } + + Spacer(modifier = Modifier.width(16.dp)) + + val value = viewModel.getValue() + @Suppress("UNCHECKED_CAST") + when (value) { + is Boolean -> { + SettingsBooleanInput(viewModel as SettingsItemViewModel) + } + + is Number -> { + SettingsNumericalInput(viewModel) + } + + is IterableEnum<*> -> { + SettingsEnumInput(viewModel as SettingsEnumItemViewModel<*>) + } + } + } + + if (!viewModel.isLastInSection) { + HorizontalDivider( + thickness = 1.dp, + color = listBackground + ) + } + } +} diff --git a/composeApp/src/commonMain/kotlin/org/noiseplanet/noisecapture/ui/features/settings/item/SettingsItemViewModel.kt b/composeApp/src/commonMain/kotlin/org/noiseplanet/noisecapture/ui/features/settings/item/SettingsItemViewModel.kt new file mode 100644 index 0000000..3d13aa2 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/org/noiseplanet/noisecapture/ui/features/settings/item/SettingsItemViewModel.kt @@ -0,0 +1,94 @@ +package org.noiseplanet.noisecapture.ui.features.settings.item + +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.flow +import kotlinx.coroutines.flow.map +import org.jetbrains.compose.resources.StringResource +import org.noiseplanet.noisecapture.services.SettingsKey +import org.noiseplanet.noisecapture.services.UserSettingsService +import org.noiseplanet.noisecapture.util.IterableEnum +import org.noiseplanet.noisecapture.util.ShortNameRepresentable + +/** + * Base setting item view model class to use with primitive types. + */ +@Suppress("LongParameterList") +open class SettingsItemViewModel( + val title: StringResource, + val description: StringResource, + val isFirstInSection: Boolean = false, + val isLastInSection: Boolean = false, + val isEnabled: Flow = flow { emit(true) }, + val settingKey: SettingsKey, + + protected val settingsService: UserSettingsService, +) { + + /** + * Returns this setting's value directly + */ + fun getValue(): T = settingsService.get(settingKey) + + /** + * Gets this setting's value as a flow to listen for value changes. + */ + fun getValueFlow(): Flow = settingsService.getFlow(settingKey) + + /** + * Sets a new value for this setting key. + */ + fun setValue(newValue: T?) = settingsService.set(settingKey, newValue) +} + + +/** + * A SettingItem subclass to be used with enums that can be stored in user defaults. + * + * The given enum must comply to both [IterableEnum] and [ShortNameRepresentable]. + */ +@Suppress("LongParameterList") +class SettingsEnumItemViewModel( + title: StringResource, + description: StringResource, + isFirstInSection: Boolean = false, + isLastInSection: Boolean = false, + isEnabled: Flow = flow { emit(true) }, + settingKey: SettingsKey, + settingsService: UserSettingsService, +) : SettingsItemViewModel( + title = title, + description = description, + isFirstInSection = isFirstInSection, + isLastInSection = isLastInSection, + isEnabled = isEnabled, + settingKey = settingKey, + settingsService = settingsService +) where T : Enum, T : IterableEnum, T : ShortNameRepresentable { + + private val entries = settingKey.defaultValue.entries() + + /** + * Lists the choices that will be available in the dropdown menu + */ + val choices: List = entries + .map { it.fullName } + + /** + * Returns the currently selected item as a string resource + */ + val selected: Flow = getValueFlow() + .map { it.shortName } + + /** + * The initial value to be displayed as a string resource + */ + val initialValue: StringResource = getValue().shortName + + /** + * Select a new value and update the underlying setting value from the index + * of the selected choice. + */ + fun select(index: Int) { + setValue(entries[index]) + } +} diff --git a/composeApp/src/commonMain/kotlin/org/noiseplanet/noisecapture/ui/features/settings/item/SettingsNumericalInput.kt b/composeApp/src/commonMain/kotlin/org/noiseplanet/noisecapture/ui/features/settings/item/SettingsNumericalInput.kt new file mode 100644 index 0000000..448baed --- /dev/null +++ b/composeApp/src/commonMain/kotlin/org/noiseplanet/noisecapture/ui/features/settings/item/SettingsNumericalInput.kt @@ -0,0 +1,156 @@ +package org.noiseplanet.noisecapture.ui.features.settings.item + +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.IntrinsicSize +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.layout.widthIn +import androidx.compose.foundation.text.BasicTextField +import androidx.compose.foundation.text.KeyboardActions +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.TextFieldDefaults +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 +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.input.nestedscroll.NestedScrollConnection +import androidx.compose.ui.input.nestedscroll.NestedScrollSource +import androidx.compose.ui.input.nestedscroll.nestedScroll +import androidx.compose.ui.platform.LocalFocusManager +import androidx.compose.ui.text.TextRange +import androidx.compose.ui.text.input.ImeAction +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.text.input.TextFieldValue +import androidx.compose.ui.text.input.VisualTransformation +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.Velocity +import androidx.compose.ui.unit.dp + +/** + * A custom text field for numerical settings values. + * + * @param viewModel View model. Must be of type [Int], [Float], or [Double]. + * @param modifier Compose [Modifier] + */ +@Composable +fun SettingsNumericalInput( + viewModel: SettingsItemViewModel, + modifier: Modifier = Modifier, +) { + @Suppress("UNCHECKED_CAST") + val defaultValue = when (viewModel.settingKey.defaultValue) { + is Int -> 0 as T + is Double -> 0.0 as T + is Float -> 0.0f as T + else -> error("Template parameter must be a numerical value") + } + + var textFieldValueState by remember { + val initialValue = viewModel.getValue().toString() + + mutableStateOf( + TextFieldValue( + text = initialValue, + selection = TextRange(initialValue.length) + ) + ) + } + + val focusManager = LocalFocusManager.current + val interactionSource = remember { MutableInteractionSource() } + + val colors = TextFieldDefaults.colors( + unfocusedContainerColor = Color.Transparent, + unfocusedIndicatorColor = Color.Transparent, + ) + val isEnabled by viewModel.isEnabled.collectAsState(true) + + @Suppress("UNCHECKED_CAST") + fun getNumericalValue(): T? { + return when (viewModel.settingKey.defaultValue) { + is Int -> textFieldValueState.text.toIntOrNull() as T? + is Double -> textFieldValueState.text.toDoubleOrNull() as T? + is Float -> textFieldValueState.text.toFloatOrNull() as T? + else -> null + } + } + + Box( + modifier = modifier.width(IntrinsicSize.Min) + ) { + BasicTextField( + value = textFieldValueState, + onValueChange = { newValue -> + textFieldValueState = newValue + // If entered value is valid, save it + getNumericalValue()?.let { + viewModel.setValue(it) + } + }, + textStyle = MaterialTheme.typography.titleMedium.copy( + textAlign = TextAlign.End, + color = MaterialTheme.colorScheme.onSurface + .copy(alpha = if (isEnabled) 1.0f else 0.5f) + ), + keyboardOptions = KeyboardOptions( + keyboardType = KeyboardType.Ascii, + imeAction = ImeAction.Done, + ), + keyboardActions = KeyboardActions(onDone = { + // Clear focus and dismiss keyboard + focusManager.clearFocus() + }), + singleLine = true, + enabled = isEnabled, + modifier = modifier + .widthIn(min = 32.dp, max = 64.dp) + .align(Alignment.CenterEnd) + .padding(vertical = 16.dp) + .padding(start = 4.dp) + .disabledHorizontalPointerInputScroll() + ) { + TextFieldDefaults.DecorationBox( + value = textFieldValueState.text, + innerTextField = it, + singleLine = true, + enabled = true, + visualTransformation = VisualTransformation.None, + interactionSource = interactionSource, + isError = getNumericalValue() == null, + contentPadding = TextFieldDefaults.contentPaddingWithoutLabel( + start = 0.dp, + end = 0.dp, + top = 0.dp, + bottom = 0.dp, + ), + prefix = null, + colors = colors, + ) + } + } +} + + +/** + * Fixes a weird scrolling behaviour when using BasicTextField with width based on IntrinsicSize.min: + * https://stackoverflow.com/questions/73309395/strange-scroll-in-the-basictextfield-with-intrinsicsize-min + */ +private val HorizontalScrollConsumer = object : NestedScrollConnection { + override fun onPreScroll(available: Offset, source: NestedScrollSource) = available.copy(y = 0f) + override suspend fun onPreFling(available: Velocity) = available.copy(y = 0f) +} + +fun Modifier.disabledHorizontalPointerInputScroll(disabled: Boolean = true) = + if (disabled) { + this.nestedScroll(HorizontalScrollConsumer) + } else { + this + } diff --git a/composeApp/src/commonMain/kotlin/org/noiseplanet/noisecapture/ui/navigation/Route.kt b/composeApp/src/commonMain/kotlin/org/noiseplanet/noisecapture/ui/navigation/Route.kt index 1d046aa..dd9cbdf 100644 --- a/composeApp/src/commonMain/kotlin/org/noiseplanet/noisecapture/ui/navigation/Route.kt +++ b/composeApp/src/commonMain/kotlin/org/noiseplanet/noisecapture/ui/navigation/Route.kt @@ -5,11 +5,15 @@ import noisecapture.composeapp.generated.resources.app_name import noisecapture.composeapp.generated.resources.measurement_title import noisecapture.composeapp.generated.resources.platform_info_title import noisecapture.composeapp.generated.resources.request_permission_title +import noisecapture.composeapp.generated.resources.settings_title import org.jetbrains.compose.resources.StringResource enum class Route(val title: StringResource) { Home(title = Res.string.app_name), + PlatformInfo(title = Res.string.platform_info_title), RequestPermission(title = Res.string.request_permission_title), - Measurement(title = Res.string.measurement_title) + Measurement(title = Res.string.measurement_title), + + Settings(title = Res.string.settings_title), } diff --git a/composeApp/src/commonMain/kotlin/org/noiseplanet/noisecapture/ui/theme/Color.kt b/composeApp/src/commonMain/kotlin/org/noiseplanet/noisecapture/ui/theme/Color.kt new file mode 100644 index 0000000..e1eeabc --- /dev/null +++ b/composeApp/src/commonMain/kotlin/org/noiseplanet/noisecapture/ui/theme/Color.kt @@ -0,0 +1,8 @@ +package org.noiseplanet.noisecapture.ui.theme + +import androidx.compose.ui.graphics.Color + +val listBackground = Color(0xFFF2F2F2) + +val contentDark = Color(0xFF1E1E1E) +val contentDark30 = contentDark.copy(alpha = 0.3f) diff --git a/composeApp/src/commonMain/kotlin/org/noiseplanet/noisecapture/util/IterableEnum.kt b/composeApp/src/commonMain/kotlin/org/noiseplanet/noisecapture/util/IterableEnum.kt new file mode 100644 index 0000000..551f685 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/org/noiseplanet/noisecapture/util/IterableEnum.kt @@ -0,0 +1,26 @@ +package org.noiseplanet.noisecapture.util + +import kotlin.enums.EnumEntries + +/** + * This is a bit of a hack to be able to access a generic enum's entry list from any instance. + * + * ```kotlin + * fun > example() { + * val entries = T.entries // doesn't compile even if T is restricted to Enum + * } + * + * fun workaround(someValue: T) where T: Enum, T: IterableEnum { + * val entries = someValue.entries() // Accessible through an enum instance + * } + * ``` + */ +interface IterableEnum> { + + /** + * Gets a list of all available enum values for this enum class. + * + * @return `T.entries` + */ + fun entries(): EnumEntries +} diff --git a/composeApp/src/commonMain/kotlin/org/noiseplanet/noisecapture/util/ShortNameRepresentable.kt b/composeApp/src/commonMain/kotlin/org/noiseplanet/noisecapture/util/ShortNameRepresentable.kt new file mode 100644 index 0000000..6951f0d --- /dev/null +++ b/composeApp/src/commonMain/kotlin/org/noiseplanet/noisecapture/util/ShortNameRepresentable.kt @@ -0,0 +1,13 @@ +package org.noiseplanet.noisecapture.util + +import org.jetbrains.compose.resources.StringResource + +/** + * Defines a data object that can be represented by an shortened name and full name. + * Useful for dropdown menus for instance. + */ +interface ShortNameRepresentable { + + val shortName: StringResource + val fullName: StringResource +} diff --git a/composeApp/src/commonMain/kotlin/org/noiseplanet/noisecapture/util/shadow/BlurMaskFilter.kt b/composeApp/src/commonMain/kotlin/org/noiseplanet/noisecapture/util/shadow/BlurMaskFilter.kt new file mode 100644 index 0000000..81dd920 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/org/noiseplanet/noisecapture/util/shadow/BlurMaskFilter.kt @@ -0,0 +1,9 @@ +package org.noiseplanet.noisecapture.util.shadow + +import androidx.compose.ui.graphics.NativePaint + +/** + * Sets the `maskFilter` property to a blur mask filter with the given radius, based on the + * current platform. + */ +expect fun NativePaint.setBlurMaskFilter(blurRadius: Float) diff --git a/composeApp/src/commonMain/kotlin/org/noiseplanet/noisecapture/util/shadow/DropShadow.kt b/composeApp/src/commonMain/kotlin/org/noiseplanet/noisecapture/util/shadow/DropShadow.kt new file mode 100644 index 0000000..e044590 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/org/noiseplanet/noisecapture/util/shadow/DropShadow.kt @@ -0,0 +1,50 @@ +package org.noiseplanet.noisecapture.util.shadow + +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.drawBehind +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.geometry.Size +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.Paint +import androidx.compose.ui.graphics.Shape +import androidx.compose.ui.graphics.drawOutline +import androidx.compose.ui.graphics.drawscope.drawIntoCanvas + +/** + * Adds a drop shadow effect to the composable. + * + * This modifier allows you to draw a shadow behind the composable with various customization options. + * + * @param shape The shape of the shadow. + * @param color The color of the shadow. + * @param blur The blur radius of the shadow + * @param offset The shadow offset along the X and Y axes. + * @param spread The amount to increase the size of the shadow. + * + * @return A new `Modifier` with the drop shadow effect applied. + */ +fun Modifier.dropShadow( + shape: Shape, + color: Color = Color.Black.copy(0.10f), + blur: Float = 4f, + offset: Offset = Offset(x = 0f, y = 4f), + spread: Float = 0f, +) = this.drawBehind { + + val shadowSize = Size(size.width + spread, size.height + spread) + val shadowOutline = shape.createOutline(shadowSize, layoutDirection, this) + + val paint = Paint() + paint.color = color + + if (blur > 0f) { + paint.asFrameworkPaint().setBlurMaskFilter(blur) + } + + drawIntoCanvas { canvas -> + canvas.save() + canvas.translate(offset.x, offset.y) + canvas.drawOutline(shadowOutline, paint) + canvas.restore() + } +} diff --git a/composeApp/src/commonTest/kotlin/IgnoreUtil.kt b/composeApp/src/commonTest/kotlin/IgnoreUtil.kt index 39e5a51..793fabd 100644 --- a/composeApp/src/commonTest/kotlin/IgnoreUtil.kt +++ b/composeApp/src/commonTest/kotlin/IgnoreUtil.kt @@ -1,3 +1,5 @@ +@file:Suppress("EXPECT_ACTUAL_CLASSIFIERS_ARE_IN_BETA_WARNING") + /** * This annotation will be implemented in iOS sources to ignore a test function or class, * but will just be a dummy annotation for Android @@ -10,4 +12,4 @@ expect annotation class IgnoreIos() * but will just be a dummy annotation for iOS */ @Target(AnnotationTarget.CLASS, AnnotationTarget.FUNCTION) -expect annotation class IgnoreAndroid +expect annotation class IgnoreAndroid() diff --git a/composeApp/src/commonTest/kotlin/org/noiseplanet/noisecapture/signal/SpectrumChannelTest.kt b/composeApp/src/commonTest/kotlin/org/noiseplanet/noisecapture/signal/SpectrumChannelTest.kt index d62cf9c..e6a42e8 100644 --- a/composeApp/src/commonTest/kotlin/org/noiseplanet/noisecapture/signal/SpectrumChannelTest.kt +++ b/composeApp/src/commonTest/kotlin/org/noiseplanet/noisecapture/signal/SpectrumChannelTest.kt @@ -1,5 +1,6 @@ package org.noiseplanet.noisecapture.signal +import IgnoreAndroid import IgnoreIos import kotlinx.coroutines.test.runTest import noisecapture.composeapp.generated.resources.Res @@ -1448,8 +1449,17 @@ class SpectrumChannelTest { * so accessing test resources is not possible. It seems like it could be solved by * using cocoapods for providing the shared framework on iOS builds but I didn't manage * to get it to work yet. + * TODO: Fix compose test resources on Android + * Since compose KMP 1.7.0+ accessing resources require Android Context to be initialized, + * which is not the case for Unit tests because they are platform independent. I'm not + * sure yet how to fix that as I didn't find much documented examples. There seems to be + * two options: + * - Transform this test to an instrumented test (i.e. UI test) + * - Find an alternative way to inject the raw data into the test case than trying to + * access the test device file system. */ @IgnoreIos + @IgnoreAndroid @OptIn(ExperimentalResourceApi::class) @Test fun testSpeak() = runTest { diff --git a/composeApp/src/iosMain/kotlin/org/noiseplanet/noisecapture/PlatformModule.kt b/composeApp/src/iosMain/kotlin/org/noiseplanet/noisecapture/PlatformModule.kt index 72cd153..afaf890 100644 --- a/composeApp/src/iosMain/kotlin/org/noiseplanet/noisecapture/PlatformModule.kt +++ b/composeApp/src/iosMain/kotlin/org/noiseplanet/noisecapture/PlatformModule.kt @@ -1,15 +1,20 @@ package org.noiseplanet.noisecapture +import com.russhwolf.settings.ExperimentalSettingsImplementation +import com.russhwolf.settings.KeychainSettings +import com.russhwolf.settings.Settings import org.koin.core.module.Module import org.koin.core.parameter.parametersOf import org.koin.dsl.module import org.noiseplanet.noisecapture.audio.AudioSource import org.noiseplanet.noisecapture.audio.IOSAudioSource import org.noiseplanet.noisecapture.log.Logger +import platform.Foundation.NSBundle /** * Registers koin components specific to this platform */ +@OptIn(ExperimentalSettingsImplementation::class) val platformModule: Module = module { factory { params -> @@ -22,4 +27,10 @@ val platformModule: Module = module { parametersOf("AudioSource") }) } + + single { + NSBundle.mainBundle.bundleIdentifier + ?.let { KeychainSettings(it) } + ?: KeychainSettings() + } } diff --git a/composeApp/src/iosMain/kotlin/org/noiseplanet/noisecapture/audio/IOSAudioSource.kt b/composeApp/src/iosMain/kotlin/org/noiseplanet/noisecapture/audio/IOSAudioSource.kt index b4ce489..5303cd9 100644 --- a/composeApp/src/iosMain/kotlin/org/noiseplanet/noisecapture/audio/IOSAudioSource.kt +++ b/composeApp/src/iosMain/kotlin/org/noiseplanet/noisecapture/audio/IOSAudioSource.kt @@ -14,6 +14,7 @@ import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.receiveAsFlow import org.noiseplanet.noisecapture.log.Logger +import org.noiseplanet.noisecapture.util.checkNoError import platform.AVFAudio.AVAudioEngine import platform.AVFAudio.AVAudioPCMBuffer import platform.AVFAudio.AVAudioSession @@ -177,18 +178,4 @@ internal class IOSAudioSource( } logger.debug("AVAudioSession is now active") } - - /** - * Checks an optional [NSError] and if it's not null, throws an [IllegalStateException] with - * a given message and the error's localized description - * - * @param error Optional [NSError] - * @param lazyMessage Provided error message - * @throws [IllegalStateException] If given [NSError] is not null. - */ - private fun checkNoError(error: NSError?, lazyMessage: () -> String) { - check(error == null) { - "${lazyMessage()}: ${error?.localizedDescription}" - } - } } diff --git a/composeApp/src/iosMain/kotlin/org/noiseplanet/noisecapture/util/NSErrorUtils.kt b/composeApp/src/iosMain/kotlin/org/noiseplanet/noisecapture/util/NSErrorUtils.kt new file mode 100644 index 0000000..77f9124 --- /dev/null +++ b/composeApp/src/iosMain/kotlin/org/noiseplanet/noisecapture/util/NSErrorUtils.kt @@ -0,0 +1,17 @@ +package org.noiseplanet.noisecapture.util + +import platform.Foundation.NSError + +/** + * Checks an optional [NSError] and if it's not null, throws an [IllegalStateException] with + * a given message and the error's localized description + * + * @param error Optional [NSError] + * @param lazyMessage Provided error message + * @throws [IllegalStateException] If given [NSError] is not null. + */ +internal fun checkNoError(error: NSError?, lazyMessage: () -> String) { + check(error == null) { + "${lazyMessage()}: ${error?.localizedDescription}" + } +} diff --git a/composeApp/src/iosMain/kotlin/org/noiseplanet/noisecapture/util/shadow/BlurMaskFilter.ios.kt b/composeApp/src/iosMain/kotlin/org/noiseplanet/noisecapture/util/shadow/BlurMaskFilter.ios.kt new file mode 100644 index 0000000..41f410c --- /dev/null +++ b/composeApp/src/iosMain/kotlin/org/noiseplanet/noisecapture/util/shadow/BlurMaskFilter.ios.kt @@ -0,0 +1,13 @@ +package org.noiseplanet.noisecapture.util.shadow + +import androidx.compose.ui.graphics.NativePaint +import org.jetbrains.skia.FilterBlurMode +import org.jetbrains.skia.MaskFilter + +actual fun NativePaint.setBlurMaskFilter(blurRadius: Float) { + this.maskFilter = MaskFilter.makeBlur( + mode = FilterBlurMode.NORMAL, + sigma = blurRadius / 2f, + respectCTM = true + ) +} diff --git a/composeApp/src/wasmJsMain/kotlin/org/noiseplanet/noisecapture/PlatformModule.kt b/composeApp/src/wasmJsMain/kotlin/org/noiseplanet/noisecapture/PlatformModule.kt index 922fa87..8706e07 100644 --- a/composeApp/src/wasmJsMain/kotlin/org/noiseplanet/noisecapture/PlatformModule.kt +++ b/composeApp/src/wasmJsMain/kotlin/org/noiseplanet/noisecapture/PlatformModule.kt @@ -1,5 +1,7 @@ package org.noiseplanet.noisecapture +import com.russhwolf.settings.Settings +import com.russhwolf.settings.StorageSettings import org.koin.core.module.Module import org.koin.core.parameter.parametersOf import org.koin.dsl.module @@ -19,4 +21,8 @@ val platformModule: Module = module { parametersOf("AudioSource") }) } + + single { + StorageSettings() + } } diff --git a/composeApp/src/wasmJsMain/kotlin/org/noiseplanet/noisecapture/util/shadow/BlurMaskFilter.wasmjs.kt b/composeApp/src/wasmJsMain/kotlin/org/noiseplanet/noisecapture/util/shadow/BlurMaskFilter.wasmjs.kt new file mode 100644 index 0000000..41f410c --- /dev/null +++ b/composeApp/src/wasmJsMain/kotlin/org/noiseplanet/noisecapture/util/shadow/BlurMaskFilter.wasmjs.kt @@ -0,0 +1,13 @@ +package org.noiseplanet.noisecapture.util.shadow + +import androidx.compose.ui.graphics.NativePaint +import org.jetbrains.skia.FilterBlurMode +import org.jetbrains.skia.MaskFilter + +actual fun NativePaint.setBlurMaskFilter(blurRadius: Float) { + this.maskFilter = MaskFilter.makeBlur( + mode = FilterBlurMode.NORMAL, + sigma = blurRadius / 2f, + respectCTM = true + ) +} diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index cf2db28..0605a00 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -1,28 +1,30 @@ # keep sorted please, use Edit -> Sort Lines [versions] -agp = "8.2.0" +agp = "8.5.2" android-compileSdk = "34" android-minSdk = "24" android-targetSdk = "34" -androidx-activityCompose = "1.9.0" +androidx-activityCompose = "1.9.3" androidx-appcompat = "1.7.0" -androidx-constraintlayout = "2.1.4" -androidx-core-ktx = "1.13.1" -androidx-espresso-core = "3.6.0" -androidx-viewmodel-compose = "2.8.0" +androidx-constraintlayout = "2.2.0" +androidx-core-ktx = "1.15.0" +androidx-espresso-core = "3.6.1" +androidx-viewmodel-compose = "2.8.4" androidx-material = "1.12.0" -androidx-navigation = "2.7.0-alpha07" -androidx-test-junit = "1.2.0" -compose-plugin = "1.6.11" +androidx-navigation = "2.8.0-alpha10" +androidx-preference = "1.2.1" +androidx-test-junit = "1.2.1" +compose-plugin = "1.7.1" coroutines = "1.8.0" detekt = "1.23.6" junit = "4.13.2" -koin = "3.6.0-Beta4" -koin-compose-multiplatform = "1.2.0-Beta4" -kotlin = "2.0.0" +koin = "4.0.0" +kotlin = "2.0.20" kotlin-wrappers = "1.0.0-pre.775" -kotlinx-datetime = "0.5.0" +kotlinx-datetime = "0.6.0" +kotlinx-serialization = "1.7.3" +settings-multiplatform = "1.2.0" [libraries] androidx-activity-compose = { module = "androidx.activity:activity-compose", version.ref = "androidx-activityCompose" } @@ -31,13 +33,15 @@ androidx-constraintlayout = { group = "androidx.constraintlayout", name = "const androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "androidx-core-ktx" } androidx-espresso-core = { group = "androidx.test.espresso", name = "espresso-core", version.ref = "androidx-espresso-core" } androidx-viewmodel-compose = { module = "org.jetbrains.androidx.lifecycle:lifecycle-viewmodel-compose", version.ref = "androidx-viewmodel-compose" } +androidx-runtime-compose = { module = "org.jetbrains.androidx.lifecycle:lifecycle-runtime-compose", version.ref = "androidx-viewmodel-compose" } androidx-material = { group = "com.google.android.material", name = "material", version.ref = "androidx-material" } androidx-navigation-compose = { module = "org.jetbrains.androidx.navigation:navigation-compose", version.ref = "androidx-navigation" } +androidx-preference = { module = "androidx.preference:preference-ktx", version.ref = "androidx-preference" } androidx-test-junit = { group = "androidx.test.ext", name = "junit", version.ref = "androidx-test-junit" } junit = { group = "junit", name = "junit", version.ref = "junit" } koin-android = { module = "io.insert-koin:koin-android", version.ref = "koin" } -koin-compose = { module = "io.insert-koin:koin-compose", version.ref = "koin-compose-multiplatform" } -koin-compose-viewmodel = { module = "io.insert-koin:koin-compose-viewmodel", version.ref = "koin-compose-multiplatform" } +koin-compose = { module = "io.insert-koin:koin-compose", version.ref = "koin" } +koin-compose-viewmodel = { module = "io.insert-koin:koin-compose-viewmodel", version.ref = "koin" } koin-core = { module = "io.insert-koin:koin-core", version.ref = "koin" } kotlin-browser = { module = "org.jetbrains.kotlin-wrappers:kotlin-browser", version.ref = "kotlin-wrappers" } kotlin-test = { module = "org.jetbrains.kotlin:kotlin-test", version.ref = "kotlin" } @@ -45,6 +49,10 @@ kotlin-test-junit = { module = "org.jetbrains.kotlin:kotlin-test-junit", version kotlinx-coroutines-core = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-core", version.ref = "coroutines" } kotlinx-coroutines-test = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-test", version.ref = "coroutines" } kotlinx-datetime = { module = "org.jetbrains.kotlinx:kotlinx-datetime", version.ref = "kotlinx-datetime" } +kotlinx-serialization = { module = "org.jetbrains.kotlinx:kotlinx-serialization-json", version.ref = "kotlinx-serialization" } +settings-multiplatform = { module = "com.russhwolf:multiplatform-settings", version.ref = "settings-multiplatform" } +settings-multiplatform-serialization = { module = "com.russhwolf:multiplatform-settings-serialization", version.ref = "settings-multiplatform" } +settings-multiplatform-coroutines = { module = "com.russhwolf:multiplatform-settings-coroutines", version.ref = "settings-multiplatform" } [plugins] androidApplication = { id = "com.android.application", version.ref = "agp" } @@ -53,3 +61,4 @@ compose-compiler = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = " detekt = { id = "io.gitlab.arturbosch.detekt", version.ref = "detekt" } jetbrainsCompose = { id = "org.jetbrains.compose", version.ref = "compose-plugin" } kotlinMultiplatform = { id = "org.jetbrains.kotlin.multiplatform", version.ref = "kotlin" } +serialization = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "kotlin" }