diff --git a/app/src/main/java/com/example/ava/services/DeviceBuilder.kt b/app/src/main/java/com/example/ava/services/DeviceBuilder.kt index a90fd4b..37c2797 100644 --- a/app/src/main/java/com/example/ava/services/DeviceBuilder.kt +++ b/app/src/main/java/com/example/ava/services/DeviceBuilder.kt @@ -20,12 +20,11 @@ import com.example.ava.server.ServerImpl import com.example.ava.settings.MicrophoneSettingsStore import com.example.ava.settings.PlayerSettingsStore import com.example.ava.settings.VoiceSatelliteSettingsStore -import com.example.ava.settings.activeStopWords -import com.example.ava.settings.activeWakeWords +import com.example.ava.settings.availableStopWords +import com.example.ava.settings.availableWakeWords import com.example.esphomeproto.api.VoiceAssistantFeature import com.example.esphomeproto.api.deviceInfoResponse import dagger.hilt.android.qualifiers.ApplicationContext -import kotlinx.coroutines.flow.first import javax.inject.Inject import kotlin.coroutines.CoroutineContext @@ -95,8 +94,8 @@ class DeviceBuilder @Inject constructor( private fun MicrophoneSettingsStore.toVoiceInput() = VoiceInputImpl( microphone = AudioRecordMicrophone(), wakeWord = MicroWakeWord(), - availableWakeWords = { availableWakeWords.first() }, - availableStopWords = { availableStopWords.first() }, + availableWakeWords = { get().availableWakeWords(context) }, + availableStopWords = { get().availableStopWords(context) }, activeWakeWords = activeWakeWords, activeStopWords = activeStopWords, muted = muted diff --git a/app/src/main/java/com/example/ava/settings/MicrophoneSettings.kt b/app/src/main/java/com/example/ava/settings/MicrophoneSettings.kt index f716eb9..d67c7d2 100644 --- a/app/src/main/java/com/example/ava/settings/MicrophoneSettings.kt +++ b/app/src/main/java/com/example/ava/settings/MicrophoneSettings.kt @@ -2,24 +2,22 @@ package com.example.ava.settings import android.content.Context import androidx.core.net.toUri -import com.example.ava.wakewords.models.WakeWordWithId +import androidx.datastore.dataStoreFile import com.example.ava.wakewords.providers.AssetWakeWordProvider import com.example.ava.wakewords.providers.DocumentTreeWakeWordProvider -import dagger.Binds import dagger.Module +import dagger.Provides import dagger.hilt.InstallIn import dagger.hilt.android.qualifiers.ApplicationContext import dagger.hilt.components.SingletonComponent -import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.combine -import kotlinx.coroutines.flow.flow import kotlinx.coroutines.flow.map -import kotlinx.coroutines.flow.mapLatest import kotlinx.serialization.Serializable import timber.log.Timber -import javax.inject.Inject import javax.inject.Singleton +private const val SETTINGS_FILE_NAME = "microphone_settings.json" + @Serializable data class MicrophoneSettings( val wakeWord: String = "okay_nabu", @@ -37,9 +35,15 @@ private val DEFAULT = MicrophoneSettings() @Suppress("unused") @Module @InstallIn(SingletonComponent::class) -abstract class MicrophoneSettingsModule() { - @Binds - abstract fun bindMicrophoneSettingsStore(microphoneSettingsStoreImpl: MicrophoneSettingsStoreImpl): MicrophoneSettingsStore +object MicrophoneSettingsModule { + @Provides + @Singleton + fun provideMicrophoneSettingsStore(@ApplicationContext context: Context): MicrophoneSettingsStore = + object : MicrophoneSettingsStore, SettingsStore by SettingsStoreImpl( + default = DEFAULT, + produceFile = { context.dataStoreFile(SETTINGS_FILE_NAME) }, + serializer = MicrophoneSettings.serializer() + ) {} } interface MicrophoneSettingsStore : SettingsStore { @@ -47,104 +51,79 @@ interface MicrophoneSettingsStore : SettingsStore { * The wake word to use for wake word detection. */ val wakeWord: SettingState + get() = setting(get = { wakeWord }, set = { copy(wakeWord = it) }) /** * Optional second wake word to use for wake word detection. */ val secondWakeWord: SettingState + get() = setting(get = { secondWakeWord }, set = { copy(secondWakeWord = it) }) /** * The stop word to use for stop word detection. */ val stopWord: SettingState + get() = setting(get = { stopWord }, set = { copy(stopWord = it) }) /** * The Uri of the directory containing custom wake words or null if not set. */ val customWakeWordLocation: SettingState + get() = setting( + get = { customWakeWordLocation }, + set = { copy(customWakeWordLocation = it) }) /** * The muted state of the microphone. */ val muted: SettingState + get() = setting(get = { muted }, set = { copy(muted = it) }) /** - * Returns a list of available wake words from configured providers. + * Helper property that allows getting and setting [wakeWord] and [secondWakeWord] as a list. */ - val availableWakeWords: Flow> + val activeWakeWords + get() = SettingState( + flow = combine(wakeWord, secondWakeWord) { wakeWord, secondWakeWord -> + listOfNotNull(wakeWord, secondWakeWord) + } + ) { + if (it.size > 0) { + wakeWord.set(it[0]) + secondWakeWord.set(it.getOrNull(1)) + } else Timber.w("Attempted to set empty active wake word list") + } /** - * Returns a list of available stop words from configured providers. + * Helper property that allows getting and setting [stopWord] as a list. */ - val availableStopWords: Flow> -} - -val MicrophoneSettingsStore.activeWakeWords - get() = SettingState( - flow = combine(wakeWord, secondWakeWord) { wakeWord, secondWakeWord -> - listOfNotNull(wakeWord, secondWakeWord) - } - ) { - if (it.size > 0) { - wakeWord.set(it[0]) - secondWakeWord.set(it.getOrNull(1)) - } else Timber.w("Attempted to set empty active wake word list") - } - -val MicrophoneSettingsStore.activeStopWords - get() = SettingState( - flow = stopWord.map { listOf(it) } - ) { - if (it.size > 0) { - stopWord.set(it[0]) - } else Timber.w("Attempted to set empty stop word list") - } - -@Singleton -class MicrophoneSettingsStoreImpl @Inject constructor(@param:ApplicationContext private val context: Context) : - MicrophoneSettingsStore, SettingsStoreImpl( - context = context, - default = DEFAULT, - fileName = "microphone_settings.json", - serializer = MicrophoneSettings.serializer() -) { - override val wakeWord = SettingState(getFlow().map { it.wakeWord }) { value -> - update { it.copy(wakeWord = value) } - } - - override val secondWakeWord = SettingState(getFlow().map { it.secondWakeWord }) { value -> - update { it.copy(secondWakeWord = value) } - } - - override val stopWord = SettingState(getFlow().map { it.stopWord }) { value -> - update { it.copy(stopWord = value) } - } - - override val customWakeWordLocation = - SettingState(getFlow().map { it.customWakeWordLocation }) { value -> - update { it.copy(customWakeWordLocation = value) } + val activeStopWords + get() = SettingState( + flow = stopWord.map { listOf(it) } + ) { + if (it.size > 0) { + stopWord.set(it[0]) + } else Timber.w("Attempted to set empty stop word list") } +} - override val muted = SettingState(getFlow().map { it.muted }) { value -> - update { it.copy(muted = value) } - } +/** + * Returns a list of available wake words from configured providers. + */ +suspend fun MicrophoneSettings.availableWakeWords(context: Context) = + if (customWakeWordLocation != null) { + AssetWakeWordProvider(assets = context.assets).get() + DocumentTreeWakeWordProvider( + context = context, + treeUri = customWakeWordLocation.toUri() + ).get() + } else AssetWakeWordProvider(assets = context.assets).get() - override val availableWakeWords = customWakeWordLocation.mapLatest { - if (it != null) - AssetWakeWordProvider(context.assets).get() + DocumentTreeWakeWordProvider( - context, - it.toUri() - ).get() - else - AssetWakeWordProvider(context.assets).get() - } - override val availableStopWords = flow { - emit( - AssetWakeWordProvider( - context.assets, - "stopWords" - ).get() - ) - } -} \ No newline at end of file +/** + * Returns a list of available stop words from configured providers. + */ +suspend fun MicrophoneSettings.availableStopWords(context: Context) = + AssetWakeWordProvider( + assets = context.assets, + path = "stopWords" + ).get() \ No newline at end of file diff --git a/app/src/main/java/com/example/ava/settings/PlayerSettings.kt b/app/src/main/java/com/example/ava/settings/PlayerSettings.kt index 0476140..58d160f 100644 --- a/app/src/main/java/com/example/ava/settings/PlayerSettings.kt +++ b/app/src/main/java/com/example/ava/settings/PlayerSettings.kt @@ -1,16 +1,17 @@ package com.example.ava.settings import android.content.Context -import dagger.Binds +import androidx.datastore.dataStoreFile import dagger.Module +import dagger.Provides import dagger.hilt.InstallIn import dagger.hilt.android.qualifiers.ApplicationContext import dagger.hilt.components.SingletonComponent -import kotlinx.coroutines.flow.map import kotlinx.serialization.Serializable -import javax.inject.Inject import javax.inject.Singleton +private const val SETTINGS_FILE_NAME = "player_settings.json" + const val defaultWakeSound = "asset:///sounds/wake_word_triggered.flac" const val defaultTimerFinishedSound = "asset:///sounds/timer_finished.flac" const val defaultErrorSound = "asset:///sounds/error.flac" @@ -35,9 +36,15 @@ private val DEFAULT = PlayerSettings() @Suppress("unused") @Module @InstallIn(SingletonComponent::class) -abstract class PlayerSettingsModule() { - @Binds - abstract fun bindPlayerSettingsStore(playerSettingsStoreImpl: PlayerSettingsStoreImpl): PlayerSettingsStore +object PlayerSettingsModule { + @Provides + @Singleton + fun providePlayerSettingsStore(@ApplicationContext context: Context): PlayerSettingsStore = + object : PlayerSettingsStore, SettingsStore by SettingsStoreImpl( + default = DEFAULT, + produceFile = { context.dataStoreFile(SETTINGS_FILE_NAME) }, + serializer = PlayerSettings.serializer() + ) {} } interface PlayerSettingsStore : SettingsStore { @@ -45,81 +52,49 @@ interface PlayerSettingsStore : SettingsStore { * The volume of the player. */ val volume: SettingState + get() = setting(get = { volume }, set = { copy(volume = it) }) /** * The muted state of the player. */ val muted: SettingState + get() = setting(get = { muted }, set = { copy(muted = it) }) /** * Whether the wake sound should be played when the wake word is triggered. */ val enableWakeSound: SettingState + get() = setting(get = { enableWakeSound }, set = { copy(enableWakeSound = it) }) /** * The path to the wake sound file. */ val wakeSound: SettingState + get() = setting(get = { wakeSound }, set = { copy(wakeSound = it) }) /** * The path to the timer finished sound file. */ val timerFinishedSound: SettingState + get() = setting(get = { timerFinishedSound }, set = { copy(timerFinishedSound = it) }) /** * Whether the timer alarm repeats until the user stops it. */ val repeatTimerFinishedSound: SettingState + get() = setting( + get = { repeatTimerFinishedSound }, + set = { copy(repeatTimerFinishedSound = it) }) /** * Whether the error sound should be played when an error occurs. */ val enableErrorSound: SettingState + get() = setting(get = { enableErrorSound }, set = { copy(enableErrorSound = it) }) /** * The path to the error sound file. */ val errorSound: SettingState -} - -@Singleton -class PlayerSettingsStoreImpl @Inject constructor(@ApplicationContext context: Context) : - PlayerSettingsStore, SettingsStoreImpl( - context = context, - default = DEFAULT, - fileName = "player_settings.json", - serializer = PlayerSettings.serializer() -) { - override val volume = SettingState(getFlow().map { it.volume }) { value -> - update { it.copy(volume = value) } - } - - override val muted = SettingState(getFlow().map { it.muted }) { value -> - update { it.copy(muted = value) } - } - - override val enableWakeSound = SettingState(getFlow().map { it.enableWakeSound }) { value -> - update { it.copy(enableWakeSound = value) } - } - override val wakeSound = SettingState(getFlow().map { it.wakeSound }) { value -> - update { it.copy(wakeSound = value) } - } - - override val timerFinishedSound = - SettingState(getFlow().map { it.timerFinishedSound }) { value -> - update { it.copy(timerFinishedSound = value) } - } - - override val repeatTimerFinishedSound = - SettingState(getFlow().map { it.repeatTimerFinishedSound }) { value -> - update { it.copy(repeatTimerFinishedSound = value) } - } - - override val enableErrorSound = SettingState(getFlow().map { it.enableErrorSound }) { value -> - update { it.copy(enableErrorSound = value) } - } - - override val errorSound = SettingState(getFlow().map { it.errorSound }) { value -> - update { it.copy(errorSound = value) } - } + get() = setting(get = { errorSound }, set = { copy(errorSound = it) }) } \ No newline at end of file diff --git a/app/src/main/java/com/example/ava/settings/SettingsSerializer.kt b/app/src/main/java/com/example/ava/settings/SettingsSerializer.kt index 0b5af92..7f3ef3e 100644 --- a/app/src/main/java/com/example/ava/settings/SettingsSerializer.kt +++ b/app/src/main/java/com/example/ava/settings/SettingsSerializer.kt @@ -3,9 +3,12 @@ package com.example.ava.settings import androidx.datastore.core.CorruptionException import androidx.datastore.core.Serializer import androidx.datastore.core.handlers.ReplaceFileCorruptionHandler +import kotlinx.serialization.ExperimentalSerializationApi import kotlinx.serialization.KSerializer import kotlinx.serialization.SerializationException import kotlinx.serialization.json.Json +import kotlinx.serialization.json.decodeFromStream +import kotlinx.serialization.json.encodeToStream import timber.log.Timber import java.io.InputStream import java.io.OutputStream @@ -15,22 +18,25 @@ fun defaultCorruptionHandler(default: T) = ReplaceFileCorruptionHandler { ex default } +@OptIn(ExperimentalSerializationApi::class) class SettingsSerializer(val serializer: KSerializer, override val defaultValue: T) : Serializer { + val json = Json { ignoreUnknownKeys = true } override suspend fun readFrom(input: InputStream): T = try { - Json.decodeFromString( - serializer, - input.readBytes().decodeToString() + json.decodeFromStream( + deserializer = serializer, + stream = input ) } catch (serialization: SerializationException) { throw CorruptionException("Unable to read Settings", serialization) } override suspend fun writeTo(t: T, output: OutputStream) { - output.write( - Json.encodeToString(serializer, t) - .encodeToByteArray() + json.encodeToStream( + serializer = serializer, + value = t, + stream = output ) } } diff --git a/app/src/main/java/com/example/ava/settings/SettingsStore.kt b/app/src/main/java/com/example/ava/settings/SettingsStore.kt index f6b7fc8..8d024f3 100644 --- a/app/src/main/java/com/example/ava/settings/SettingsStore.kt +++ b/app/src/main/java/com/example/ava/settings/SettingsStore.kt @@ -1,48 +1,57 @@ package com.example.ava.settings -import android.content.Context -import androidx.datastore.core.DataStore +import androidx.datastore.core.DataStoreFactory import androidx.datastore.core.IOException -import androidx.datastore.dataStore import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.catch +import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.onEach import kotlinx.serialization.KSerializer import timber.log.Timber +import java.io.File interface SettingsStore { - fun getFlow(): Flow + fun getFlow(): Flow = getFlow { this } + fun getFlow(transform: T.() -> R): Flow suspend fun get(): T suspend fun update(transform: suspend (T) -> T) } -open class SettingsStoreImpl( - private val context: Context, +fun SettingsStore.setting( + get: T.() -> S, + set: T.(S) -> T +) = SettingState( + flow = getFlow(get), + set = { value -> update { it.set(value) } } +) + +class SettingsStoreImpl( private val default: T, - fileName: String, + produceFile: () -> File, serializer: KSerializer -) : - SettingsStore { - - private val Context.dataStore: DataStore by dataStore( - fileName, - SettingsSerializer(serializer, default), - corruptionHandler = defaultCorruptionHandler(default) +) : SettingsStore { + private val dataStore = DataStoreFactory.create( + serializer = SettingsSerializer(serializer, default), + corruptionHandler = defaultCorruptionHandler(default), + produceFile = produceFile ) - override fun getFlow() = context.dataStore.data + override fun getFlow(transform: T.() -> R) = dataStore.data .catch { exception -> if (exception is IOException) { Timber.e(exception, "Error reading settings, returning defaults") emit(default) } else throw exception } + .map(transform) + .distinctUntilChanged() .onEach { Timber.d("Loaded settings: $it") } override suspend fun get(): T = getFlow().first() override suspend fun update(transform: suspend (T) -> T) { - context.dataStore.updateData(transform) + dataStore.updateData(transform) } } \ No newline at end of file diff --git a/app/src/main/java/com/example/ava/settings/VoiceSatelliteSettings.kt b/app/src/main/java/com/example/ava/settings/VoiceSatelliteSettings.kt index f951c50..8bedb74 100644 --- a/app/src/main/java/com/example/ava/settings/VoiceSatelliteSettings.kt +++ b/app/src/main/java/com/example/ava/settings/VoiceSatelliteSettings.kt @@ -1,18 +1,19 @@ package com.example.ava.settings import android.content.Context +import androidx.datastore.dataStoreFile import com.example.ava.server.DEFAULT_SERVER_PORT import com.example.ava.utils.getRandomMacAddressString -import dagger.Binds import dagger.Module +import dagger.Provides import dagger.hilt.InstallIn import dagger.hilt.android.qualifiers.ApplicationContext import dagger.hilt.components.SingletonComponent -import kotlinx.coroutines.flow.map import kotlinx.serialization.Serializable -import javax.inject.Inject import javax.inject.Singleton +private const val SETTINGS_FILE_NAME = "voice_satellite_settings.json" + // The voice satellite uses a mac address as a unique identifier. // The use of the actual mac address on Android is discouraged/not available // depending on the Android version. @@ -37,9 +38,16 @@ private val DEFAULT = VoiceSatelliteSettings() @Suppress("unused") @Module @InstallIn(SingletonComponent::class) -abstract class VoiceSatelliteSettingsModule() { - @Binds - abstract fun bindVoiceSatelliteSettingsStore(voiceSatelliteSettingsStoreImpl: VoiceSatelliteSettingsStoreImpl): VoiceSatelliteSettingsStore +object VoiceSatelliteSettingsModule { + @Provides + @Singleton + fun provideVoiceSatelliteSettingsStore(@ApplicationContext context: Context): VoiceSatelliteSettingsStore = + object : VoiceSatelliteSettingsStore, + SettingsStore by SettingsStoreImpl( + default = DEFAULT, + produceFile = { context.dataStoreFile(SETTINGS_FILE_NAME) }, + serializer = VoiceSatelliteSettings.serializer() + ) {} } interface VoiceSatelliteSettingsStore : SettingsStore { @@ -47,44 +55,24 @@ interface VoiceSatelliteSettingsStore : SettingsStore { * The display name of the voice satellite. */ val name: SettingState + get() = setting(get = { name }, set = { copy(name = it) }) /** * The port the voice satellite should listen on. */ val serverPort: SettingState + get() = setting(get = { serverPort }, set = { copy(serverPort = it) }) /** * Whether the voice satellite should be started automatically when the app is opened. */ val autoStart: SettingState + get() = setting(get = { autoStart }, set = { copy(autoStart = it) }) /** * Ensures that a mac address has been generated and persisted. */ - suspend fun ensureMacAddressIsSet() -} - -@Singleton -class VoiceSatelliteSettingsStoreImpl @Inject constructor(@ApplicationContext context: Context) : - VoiceSatelliteSettingsStore, SettingsStoreImpl( - context = context, - default = DEFAULT, - fileName = "voice_satellite_settings.json", - serializer = VoiceSatelliteSettings.serializer() -) { - override val name = SettingState(getFlow().map { it.name }) { value -> - update { it.copy(name = value) } - } - - override val serverPort = SettingState(getFlow().map { it.serverPort }) { value -> - update { it.copy(serverPort = value) } - } - - override val autoStart = SettingState(getFlow().map { it.autoStart }) { value -> - update { it.copy(autoStart = value) } - } - - override suspend fun ensureMacAddressIsSet() { + suspend fun ensureMacAddressIsSet() { update { if (it.macAddress == DEFAULT_MAC_ADDRESS) it.copy(macAddress = getRandomMacAddressString()) else it } diff --git a/app/src/main/java/com/example/ava/ui/screens/BackNavigationScreen.kt b/app/src/main/java/com/example/ava/ui/screens/BackNavigationScreen.kt new file mode 100644 index 0000000..ca6d23a --- /dev/null +++ b/app/src/main/java/com/example/ava/ui/screens/BackNavigationScreen.kt @@ -0,0 +1,57 @@ +package com.example.ava.ui.screens + +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBar +import androidx.compose.material3.TopAppBarDefaults.topAppBarColors +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.painterResource +import androidx.navigation.NavController +import com.example.ava.R + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun BackNavigationScreen( + navController: NavController, + title: String, + content: @Composable (PaddingValues) -> Unit +) { + Scaffold( + modifier = Modifier.fillMaxSize(), + topBar = { BackNavigationBar(navController, title) }, + content = content + ) +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun BackNavigationBar( + navController: NavController, + title: String, +) = TopAppBar( + colors = topAppBarColors( + containerColor = MaterialTheme.colorScheme.primaryContainer, + titleContentColor = MaterialTheme.colorScheme.onPrimaryContainer, + ), + title = { + Text(title) + }, + navigationIcon = { + IconButton( + onClick = { navController.popBackStack() } + ) { + Icon( + painter = painterResource(R.drawable.arrow_back_24px), + contentDescription = "Back", + tint = MaterialTheme.colorScheme.onPrimaryContainer + ) + } + } +) \ No newline at end of file diff --git a/app/src/main/java/com/example/ava/ui/screens/settings/SettingsScreen.kt b/app/src/main/java/com/example/ava/ui/screens/settings/SettingsScreen.kt index ad04327..05b9919 100644 --- a/app/src/main/java/com/example/ava/ui/screens/settings/SettingsScreen.kt +++ b/app/src/main/java/com/example/ava/ui/screens/settings/SettingsScreen.kt @@ -2,53 +2,202 @@ package com.example.ava.ui.screens.settings import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.material3.ExperimentalMaterial3Api -import androidx.compose.material3.Icon -import androidx.compose.material3.IconButton -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Scaffold -import androidx.compose.material3.Text -import androidx.compose.material3.TopAppBar -import androidx.compose.material3.TopAppBarDefaults.topAppBarColors +import androidx.compose.material3.HorizontalDivider import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue import androidx.compose.ui.Modifier -import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource +import androidx.core.net.toUri +import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.navigation.NavController import com.example.ava.R +import com.example.ava.settings.defaultTimerFinishedSound +import com.example.ava.settings.defaultWakeSound +import com.example.ava.ui.screens.BackNavigationScreen +import com.example.ava.ui.screens.settings.components.DocumentSetting +import com.example.ava.ui.screens.settings.components.DocumentTreeSetting +import com.example.ava.ui.screens.settings.components.IntSetting +import com.example.ava.ui.screens.settings.components.SelectSetting +import com.example.ava.ui.screens.settings.components.SwitchSetting +import com.example.ava.ui.screens.settings.components.TextSetting @OptIn(ExperimentalMaterial3Api::class) @Composable -fun SettingsScreen(navController: NavController) { - Scaffold( - modifier = Modifier.fillMaxSize(), - topBar = { - TopAppBar( - colors = topAppBarColors( - containerColor = MaterialTheme.colorScheme.primaryContainer, - titleContentColor = MaterialTheme.colorScheme.onPrimaryContainer, - ), - title = { - Text(stringResource(R.string.label_settings)) +fun SettingsScreen( + navController: NavController, + viewModel: SettingsViewModel = hiltViewModel() +) = BackNavigationScreen( + navController = navController, + title = stringResource(R.string.label_settings), +) { innerPadding -> + val satelliteState by viewModel.satelliteSettingsState.collectAsStateWithLifecycle(null) + val microphoneState by viewModel.microphoneSettingsState.collectAsStateWithLifecycle(null) + val playerState by viewModel.playerSettingsState.collectAsStateWithLifecycle(null) + val disabledLabel = stringResource(R.string.label_disabled) + + LazyColumn( + modifier = Modifier + .fillMaxSize() + .padding(innerPadding) + ) { + val enabled = satelliteState != null + item { + TextSetting( + name = stringResource(R.string.label_voice_satellite_name), + value = satelliteState?.name ?: "", + enabled = enabled, + validation = { viewModel.validateName(it) }, + onConfirmRequest = { viewModel.saveName(it) } + ) + } + item { + IntSetting( + name = stringResource(R.string.label_voice_satellite_port), + value = satelliteState?.serverPort, + enabled = enabled, + validation = { viewModel.validatePort(it) }, + onConfirmRequest = { viewModel.saveServerPort(it) } + ) + } + item { + SwitchSetting( + name = stringResource(R.string.label_voice_satellite_autostart), + description = stringResource(R.string.description_voice_satellite_autostart), + value = satelliteState?.autoStart ?: false, + enabled = enabled, + onCheckedChange = { viewModel.saveAutoStart(it) } + ) + } + item { + HorizontalDivider() + } + item { + SelectSetting( + name = stringResource(R.string.label_voice_satellite_first_wake_word), + selected = microphoneState?.wakeWord, + items = microphoneState?.wakeWords, + enabled = enabled, + key = { it.id }, + value = { it?.wakeWord?.wake_word ?: "" }, + onConfirmRequest = { + if (it != null) { + viewModel.saveWakeWord(it.id, microphoneState?.wakeWords ?: emptyList()) + } + } + ) + } + item { + SelectSetting( + name = stringResource(R.string.label_voice_satellite_second_wake_word), + selected = microphoneState?.secondWakeWord, + items = microphoneState?.wakeWords, + enabled = enabled, + key = { it.id }, + value = { it?.wakeWord?.wake_word ?: disabledLabel }, + onClearRequest = { viewModel.saveSecondWakeWord(null, null) }, + onConfirmRequest = { + if (it != null) { + viewModel.saveSecondWakeWord(it.id, microphoneState?.wakeWords) + } + } + ) + } + item { + DocumentTreeSetting( + name = stringResource(R.string.label_custom_wake_words), + description = stringResource(R.string.description_custom_wake_word_location), + value = microphoneState?.customWakeWordLocation, + enabled = enabled, + onResult = { + if (it != null) { + viewModel.saveCustomWakeWordDirectory(it) + } }, - navigationIcon = { - IconButton( - onClick = { navController.popBackStack() } - ) { - Icon( - painter = painterResource(R.drawable.arrow_back_24px), - contentDescription = "Back", - tint = MaterialTheme.colorScheme.onPrimaryContainer - ) + onClearRequest = { viewModel.resetCustomWakeWordDirectory() } + ) + } + item { + SwitchSetting( + name = stringResource(R.string.label_voice_satellite_enable_wake_sound), + description = stringResource(R.string.description_voice_satellite_play_wake_sound), + value = playerState?.enableWakeSound ?: true, + enabled = enabled, + onCheckedChange = { viewModel.saveEnableWakeSound(it) } + ) + } + item { + DocumentSetting( + name = stringResource(R.string.label_custom_wake_sound), + description = stringResource(R.string.description_custom_wake_sound_location), + value = if (playerState?.wakeSound != defaultWakeSound) playerState?.wakeSound?.toUri() else null, + enabled = enabled, + mimeTypes = arrayOf("audio/*"), + onResult = { + if (it != null) { + viewModel.saveWakeSound(it) } + }, + onClearRequest = { + viewModel.resetWakeSound() } ) } - ) { innerPadding -> - VoiceSatelliteSettings( - modifier = Modifier - .fillMaxSize() - .padding(innerPadding) - ) + item { + HorizontalDivider() + } + item { + DocumentSetting( + name = stringResource(R.string.label_custom_timer_sound), + description = stringResource(R.string.description_custom_timer_sound_location), + value = if (playerState?.timerFinishedSound != defaultTimerFinishedSound) playerState?.timerFinishedSound?.toUri() else null, + enabled = enabled, + mimeTypes = arrayOf("audio/*"), + onResult = { + if (it != null) { + viewModel.saveTimerFinishedSound(it) + } + }, + onClearRequest = { viewModel.resetTimerFinishedSound() } + ) + } + item { + SwitchSetting( + name = stringResource(R.string.label_timer_sound_repeat), + description = stringResource(R.string.description_timer_sound_repeat), + value = playerState?.repeatTimerFinishedSound ?: true, + enabled = enabled, + onCheckedChange = { viewModel.saveRepeatTimerFinishedSound(it) } + ) + } + item { + HorizontalDivider() + } + item { + SwitchSetting( + name = stringResource(R.string.label_voice_satellite_enable_error_sound), + description = stringResource(R.string.description_voice_satellite_enable_error_sound), + value = playerState?.enableErrorSound ?: false, + enabled = enabled, + onCheckedChange = { viewModel.saveEnableErrorSound(it) } + ) + } + item { + DocumentSetting( + name = stringResource(R.string.label_custom_error_sound), + description = stringResource(R.string.description_custom_error_sound_location), + value = playerState?.errorSound?.toUri(), + enabled = enabled, + mimeTypes = arrayOf("audio/*"), + onResult = { + if (it != null) { + viewModel.saveErrorSound(it) + } + }, + onClearRequest = { viewModel.resetErrorSound() } + ) + } } } \ No newline at end of file diff --git a/app/src/main/java/com/example/ava/ui/screens/settings/SettingsViewModel.kt b/app/src/main/java/com/example/ava/ui/screens/settings/SettingsViewModel.kt index 6c16a4e..cad8368 100644 --- a/app/src/main/java/com/example/ava/ui/screens/settings/SettingsViewModel.kt +++ b/app/src/main/java/com/example/ava/ui/screens/settings/SettingsViewModel.kt @@ -6,18 +6,20 @@ import android.net.Uri import androidx.compose.runtime.Immutable import androidx.core.net.toUri import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope import com.example.ava.R import com.example.ava.settings.MicrophoneSettingsStore import com.example.ava.settings.PlayerSettingsStore import com.example.ava.settings.VoiceSatelliteSettingsStore +import com.example.ava.settings.availableWakeWords import com.example.ava.settings.defaultErrorSound import com.example.ava.settings.defaultTimerFinishedSound import com.example.ava.settings.defaultWakeSound import com.example.ava.wakewords.models.WakeWordWithId import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.qualifiers.ApplicationContext -import kotlinx.coroutines.flow.combine -import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.launch import timber.log.Timber import javax.inject.Inject @@ -38,10 +40,8 @@ class SettingsViewModel @Inject constructor( ) : ViewModel() { val satelliteSettingsState = satelliteSettingsStore.getFlow() - val microphoneSettingsState = combine( - microphoneSettingsStore.getFlow(), - microphoneSettingsStore.availableWakeWords - ) { settings, wakeWords -> + val microphoneSettingsState = microphoneSettingsStore.getFlow().map { settings -> + val wakeWords = settings.availableWakeWords(context) MicrophoneState( wakeWord = wakeWords.firstOrNull { wakeWord -> wakeWord.id == settings.wakeWord @@ -50,14 +50,13 @@ class SettingsViewModel @Inject constructor( wakeWord.id == settings.secondWakeWord }, wakeWords = wakeWords, - customWakeWordLocation = settings.customWakeWordLocation?.toUri() ) } val playerSettingsState = playerSettingsStore.getFlow() - suspend fun saveName(name: String) { + fun saveName(name: String) = viewModelScope.launch { if (validateName(name).isNullOrBlank()) { satelliteSettingsStore.name.set(name) } else { @@ -65,7 +64,7 @@ class SettingsViewModel @Inject constructor( } } - suspend fun saveServerPort(port: Int?) { + fun saveServerPort(port: Int?) = viewModelScope.launch { if (validatePort(port).isNullOrBlank()) { satelliteSettingsStore.serverPort.set(port!!) } else { @@ -73,27 +72,29 @@ class SettingsViewModel @Inject constructor( } } - suspend fun saveAutoStart(autoStart: Boolean) { + fun saveAutoStart(autoStart: Boolean) = viewModelScope.launch { satelliteSettingsStore.autoStart.set(autoStart) } - suspend fun saveWakeWord(wakeWordId: String) { - if (validateWakeWord(wakeWordId).isNullOrBlank()) { - microphoneSettingsStore.wakeWord.set(wakeWordId) - } else { - Timber.w("Cannot save invalid wake word: $wakeWordId") + fun saveWakeWord(wakeWordId: String, availableWakeWords: List) = + viewModelScope.launch { + if (availableWakeWords.any { it.id == wakeWordId }) { + microphoneSettingsStore.wakeWord.set(wakeWordId) + } else { + Timber.w("Cannot save unknown wake word: $wakeWordId") + } } - } - suspend fun saveSecondWakeWord(wakeWordId: String?) { - if (wakeWordId == null || validateWakeWord(wakeWordId).isNullOrBlank()) { - microphoneSettingsStore.secondWakeWord.set(wakeWordId) - } else { - Timber.w("Cannot save invalid wake word: $wakeWordId") + fun saveSecondWakeWord(wakeWordId: String?, availableWakeWords: List?) = + viewModelScope.launch { + if (wakeWordId == null || availableWakeWords?.any { it.id == wakeWordId } ?: false) { + microphoneSettingsStore.secondWakeWord.set(wakeWordId) + } else { + Timber.w("Cannot save unknown wake word: $wakeWordId") + } } - } - suspend fun saveCustomWakeWordDirectory(uri: Uri?) { + fun saveCustomWakeWordDirectory(uri: Uri?) = viewModelScope.launch { if (uri != null) { // Get persistable permission to read from the location // ToDo: This should potentially handled elsewhere @@ -105,15 +106,15 @@ class SettingsViewModel @Inject constructor( } } - suspend fun resetCustomWakeWordDirectory() { + fun resetCustomWakeWordDirectory() = viewModelScope.launch { microphoneSettingsStore.customWakeWordLocation.set(null) } - suspend fun saveEnableWakeSound(enableWakeSound: Boolean) { + fun saveEnableWakeSound(enableWakeSound: Boolean) = viewModelScope.launch { playerSettingsStore.enableWakeSound.set(enableWakeSound) } - suspend fun saveWakeSound(uri: Uri?) { + fun saveWakeSound(uri: Uri?) = viewModelScope.launch { if (uri != null) { // Get persistable permission to read from the location // ToDo: This should potentially handled elsewhere @@ -125,11 +126,11 @@ class SettingsViewModel @Inject constructor( } } - suspend fun resetWakeSound() { + fun resetWakeSound() = viewModelScope.launch { playerSettingsStore.wakeSound.set(defaultWakeSound) } - suspend fun saveTimerFinishedSound(uri: Uri?) { + fun saveTimerFinishedSound(uri: Uri?) = viewModelScope.launch { if (uri != null) { // Get persistable permission to read from the location // ToDo: This should potentially handled elsewhere @@ -141,19 +142,19 @@ class SettingsViewModel @Inject constructor( } } - suspend fun resetTimerFinishedSound() { + fun resetTimerFinishedSound() = viewModelScope.launch { playerSettingsStore.timerFinishedSound.set(defaultTimerFinishedSound) } - suspend fun saveRepeatTimerFinishedSound(repeatTimerFinishedSound: Boolean) { + fun saveRepeatTimerFinishedSound(repeatTimerFinishedSound: Boolean) = viewModelScope.launch { playerSettingsStore.repeatTimerFinishedSound.set(repeatTimerFinishedSound) } - suspend fun saveEnableErrorSound(enableErrorSound: Boolean) { + fun saveEnableErrorSound(enableErrorSound: Boolean) = viewModelScope.launch { playerSettingsStore.enableErrorSound.set(enableErrorSound) } - suspend fun saveErrorSound(uri: Uri?) { + fun saveErrorSound(uri: Uri?) = viewModelScope.launch { if (uri != null) { context.contentResolver.takePersistableUriPermission( uri, @@ -163,7 +164,7 @@ class SettingsViewModel @Inject constructor( } } - suspend fun resetErrorSound() { + fun resetErrorSound() = viewModelScope.launch { playerSettingsStore.errorSound.set(defaultErrorSound) } @@ -177,13 +178,4 @@ class SettingsViewModel @Inject constructor( if (port == null || port < 1 || port > 65535) context.getString(R.string.validation_voice_satellite_port_invalid) else null - - suspend fun validateWakeWord(wakeWordId: String): String? { - val wakeWordWithId = microphoneSettingsStore.availableWakeWords.first() - .firstOrNull { it.id == wakeWordId } - return if (wakeWordWithId == null) - context.getString(R.string.validation_voice_satellite_wake_word_invalid) - else - null - } } \ No newline at end of file diff --git a/app/src/main/java/com/example/ava/ui/screens/settings/VoiceSatelliteSettings.kt b/app/src/main/java/com/example/ava/ui/screens/settings/VoiceSatelliteSettings.kt deleted file mode 100644 index 1191268..0000000 --- a/app/src/main/java/com/example/ava/ui/screens/settings/VoiceSatelliteSettings.kt +++ /dev/null @@ -1,241 +0,0 @@ -package com.example.ava.ui.screens.settings - -import androidx.compose.foundation.lazy.LazyColumn -import androidx.compose.material3.HorizontalDivider -import androidx.compose.runtime.Composable -import androidx.compose.runtime.getValue -import androidx.compose.runtime.rememberCoroutineScope -import androidx.compose.ui.Modifier -import androidx.compose.ui.res.stringResource -import androidx.core.net.toUri -import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel -import androidx.lifecycle.compose.collectAsStateWithLifecycle -import com.example.ava.R -import com.example.ava.settings.defaultTimerFinishedSound -import com.example.ava.settings.defaultWakeSound -import com.example.ava.ui.screens.settings.components.DocumentSetting -import com.example.ava.ui.screens.settings.components.DocumentTreeSetting -import com.example.ava.ui.screens.settings.components.IntSetting -import com.example.ava.ui.screens.settings.components.SelectSetting -import com.example.ava.ui.screens.settings.components.SwitchSetting -import com.example.ava.ui.screens.settings.components.TextSetting -import kotlinx.coroutines.launch - -@Composable -fun VoiceSatelliteSettings( - modifier: Modifier = Modifier, - viewModel: SettingsViewModel = hiltViewModel() -) { - val coroutineScope = rememberCoroutineScope() - val satelliteState by viewModel.satelliteSettingsState.collectAsStateWithLifecycle(null) - val microphoneState by viewModel.microphoneSettingsState.collectAsStateWithLifecycle(null) - val playerState by viewModel.playerSettingsState.collectAsStateWithLifecycle(null) - val disabledLabel = stringResource(R.string.label_disabled) - - LazyColumn( - modifier = modifier - ) { - val enabled = satelliteState != null - item { - TextSetting( - name = stringResource(R.string.label_voice_satellite_name), - value = satelliteState?.name ?: "", - enabled = enabled, - validation = { viewModel.validateName(it) }, - onConfirmRequest = { - coroutineScope.launch { - viewModel.saveName(it) - } - } - ) - } - item { - IntSetting( - name = stringResource(R.string.label_voice_satellite_port), - value = satelliteState?.serverPort, - enabled = enabled, - validation = { viewModel.validatePort(it) }, - onConfirmRequest = { - coroutineScope.launch { - viewModel.saveServerPort(it) - } - } - ) - } - item { - SwitchSetting( - name = stringResource(R.string.label_voice_satellite_autostart), - description = stringResource(R.string.description_voice_satellite_autostart), - value = satelliteState?.autoStart ?: false, - enabled = enabled, - onCheckedChange = { - coroutineScope.launch { - viewModel.saveAutoStart(it) - } - } - ) - } - item { - HorizontalDivider() - } - item { - SelectSetting( - name = stringResource(R.string.label_voice_satellite_first_wake_word), - selected = microphoneState?.wakeWord, - items = microphoneState?.wakeWords, - enabled = enabled, - key = { it.id }, - value = { it?.wakeWord?.wake_word ?: "" }, - onConfirmRequest = { - if (it != null) { - coroutineScope.launch { - viewModel.saveWakeWord(it.id) - } - } - } - ) - } - item { - SelectSetting( - name = stringResource(R.string.label_voice_satellite_second_wake_word), - selected = microphoneState?.secondWakeWord, - items = microphoneState?.wakeWords, - enabled = enabled, - key = { it.id }, - value = { it?.wakeWord?.wake_word ?: disabledLabel }, - onClearRequest = { - coroutineScope.launch { - viewModel.saveSecondWakeWord(null) - } - }, - onConfirmRequest = { - if (it != null) { - coroutineScope.launch { - viewModel.saveSecondWakeWord(it.id) - } - } - } - ) - } - item { - DocumentTreeSetting( - name = stringResource(R.string.label_custom_wake_words), - description = stringResource(R.string.description_custom_wake_word_location), - value = microphoneState?.customWakeWordLocation, - enabled = enabled, - onResult = { - if (it != null) { - coroutineScope.launch { - viewModel.saveCustomWakeWordDirectory(it) - } - } - }, - onClearRequest = { - coroutineScope.launch { viewModel.resetCustomWakeWordDirectory() } - } - ) - } - item { - SwitchSetting( - name = stringResource(R.string.label_voice_satellite_enable_wake_sound), - description = stringResource(R.string.description_voice_satellite_play_wake_sound), - value = playerState?.enableWakeSound ?: true, - enabled = enabled, - onCheckedChange = { - coroutineScope.launch { - viewModel.saveEnableWakeSound(it) - } - } - ) - } - item { - DocumentSetting( - name = stringResource(R.string.label_custom_wake_sound), - description = stringResource(R.string.description_custom_wake_sound_location), - value = if (playerState?.wakeSound != defaultWakeSound) playerState?.wakeSound?.toUri() else null, - enabled = enabled, - mimeTypes = arrayOf("audio/*"), - onResult = { - if (it != null) { - coroutineScope.launch { - viewModel.saveWakeSound(it) - } - } - }, - onClearRequest = { - coroutineScope.launch { viewModel.resetWakeSound() } - } - ) - } - item { - HorizontalDivider() - } - item { - DocumentSetting( - name = stringResource(R.string.label_custom_timer_sound), - description = stringResource(R.string.description_custom_timer_sound_location), - value = if (playerState?.timerFinishedSound != defaultTimerFinishedSound) playerState?.timerFinishedSound?.toUri() else null, - enabled = enabled, - mimeTypes = arrayOf("audio/*"), - onResult = { - if (it != null) { - coroutineScope.launch { - viewModel.saveTimerFinishedSound(it) - } - } - }, - onClearRequest = { - coroutineScope.launch { viewModel.resetTimerFinishedSound() } - } - ) - } - item { - SwitchSetting( - name = stringResource(R.string.label_timer_sound_repeat), - description = stringResource(R.string.description_timer_sound_repeat), - value = playerState?.repeatTimerFinishedSound ?: true, - enabled = enabled, - onCheckedChange = { - coroutineScope.launch { - viewModel.saveRepeatTimerFinishedSound(it) - } - } - ) - } - item { - HorizontalDivider() - } - item { - SwitchSetting( - name = stringResource(R.string.label_voice_satellite_enable_error_sound), - description = stringResource(R.string.description_voice_satellite_enable_error_sound), - value = playerState?.enableErrorSound ?: false, - enabled = enabled, - onCheckedChange = { - coroutineScope.launch { - viewModel.saveEnableErrorSound(it) - } - } - ) - } - item { - DocumentSetting( - name = stringResource(R.string.label_custom_error_sound), - description = stringResource(R.string.description_custom_error_sound_location), - value = playerState?.errorSound?.toUri(), - enabled = enabled, - mimeTypes = arrayOf("audio/*"), - onResult = { - if (it != null) { - coroutineScope.launch { - viewModel.saveErrorSound(it) - } - } - }, - onClearRequest = { - coroutineScope.launch { viewModel.resetErrorSound() } - } - ) - } - } -} \ No newline at end of file diff --git a/app/src/main/java/com/example/ava/ui/screens/settings/components/SelectSetting.kt b/app/src/main/java/com/example/ava/ui/screens/settings/components/SelectSetting.kt index 2e592b4..e42b092 100644 --- a/app/src/main/java/com/example/ava/ui/screens/settings/components/SelectSetting.kt +++ b/app/src/main/java/com/example/ava/ui/screens/settings/components/SelectSetting.kt @@ -1,5 +1,6 @@ package com.example.ava.ui.screens.settings.components +import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding @@ -31,6 +32,7 @@ fun SelectSetting( enabled: Boolean = true, key: ((T) -> Any)? = null, value: (T?) -> String = { it.toString() }, + itemDescription: @Composable (T) -> String? = { null }, onConfirmRequest: (T?) -> Unit = {}, onClearRequest: (() -> Unit)? = null ) { @@ -57,6 +59,7 @@ fun SelectSetting( items = items, key = key, value = value, + itemDescription = itemDescription, onConfirmRequest = onConfirmRequest ) } @@ -70,6 +73,7 @@ fun DialogScope.SelectDialog( items: List?, key: ((T) -> Any)? = null, value: (T) -> String = { it.toString() }, + itemDescription: @Composable (T) -> String? = { null }, onConfirmRequest: (T?) -> Unit, ) { var selectedItem by remember { mutableStateOf(selected) } @@ -86,25 +90,35 @@ fun DialogScope.SelectDialog( items = items, key = key ) { item -> - Row( + Column( modifier = Modifier .fillMaxWidth() - .padding(vertical = 16.dp) + .padding(vertical = 8.dp) .selectable( selected = (item == selectedItem), onClick = { selectedItem = item }, role = Role.RadioButton ) ) { - RadioButton( - modifier = Modifier.padding(horizontal = 8.dp), - selected = item == selectedItem, - onClick = null - ) - Text( - text = value(item), - style = MaterialTheme.typography.titleMedium - ) + Row { + RadioButton( + modifier = Modifier.padding(horizontal = 8.dp), + selected = item == selectedItem, + onClick = null + ) + Text( + text = value(item), + style = MaterialTheme.typography.titleMedium + ) + } + val description = itemDescription(item) + if (description != null) { + Text( + modifier = Modifier.padding(horizontal = 8.dp, vertical = 4.dp), + text = description, + style = MaterialTheme.typography.bodyMedium + ) + } } } } diff --git a/app/src/main/java/com/example/ava/ui/screens/settings/components/SettingItem.kt b/app/src/main/java/com/example/ava/ui/screens/settings/components/SettingItem.kt index 2a5fec9..9bf97a7 100644 --- a/app/src/main/java/com/example/ava/ui/screens/settings/components/SettingItem.kt +++ b/app/src/main/java/com/example/ava/ui/screens/settings/components/SettingItem.kt @@ -37,15 +37,19 @@ fun SettingItem( fun RowScope.Details(name: String, description: String = "", value: String = "") { Column(Modifier.weight(1f)) { Text( - name, + text = name, color = MaterialTheme.colorScheme.primary, style = MaterialTheme.typography.titleMedium ) if (description.isNotBlank()) { - Text(description, style = MaterialTheme.typography.bodyMedium) + Text(text = description, style = MaterialTheme.typography.bodyMedium) } if (value.isNotBlank()) { - Text(value, style = MaterialTheme.typography.bodyMedium) + Text( + text = value, + modifier = Modifier.padding(top = 4.dp), + style = MaterialTheme.typography.bodyMedium + ) } } }