diff --git a/README.md b/README.md index c651f50..009e3ed 100644 --- a/README.md +++ b/README.md @@ -47,6 +47,8 @@ Once connected, the satellite is fully configurable from within Home Assistant a - Enable wake sound: Whether to play a sound when the satellite is woken by a wake word - Custom timer sound: Specify an audio file to play instead of the default one - Repeat timer sound: By default, the timer sound is repeated until stopped by the user +- Hide system bars: Run the main screen in full screen mode by hiding status and navigation bars +- Wake screen on activity: Turn on the screen when a wake word is detected or timers are active. Only when the Ava screen is displayed, and the lock screen is disabled # Custom wake word models The app includes a default [set of wake words](https://github.com/brownard/Ava/tree/master/app/src/main/assets/wakeWords), however you can also specify a directory containing custom wake words supported by microWakeWord. diff --git a/app/src/main/java/com/example/ava/settings/DisplaySettings.kt b/app/src/main/java/com/example/ava/settings/DisplaySettings.kt new file mode 100644 index 0000000..c2d57da --- /dev/null +++ b/app/src/main/java/com/example/ava/settings/DisplaySettings.kt @@ -0,0 +1,59 @@ +package com.example.ava.settings + +import android.content.Context +import dagger.Binds +import dagger.Module +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 + +@Serializable +data class DisplaySettings( + val wakeScreen: Boolean = false, + val hideSystemBars: Boolean = false +) + +private val DEFAULT = DisplaySettings() + +/** + * Used to inject a concrete implementation of DisplaySettingsStore + */ +@Module +@InstallIn(SingletonComponent::class) +abstract class DisplaySettingsModule() { + @Binds + abstract fun bindDisplaySettingsStore(displaySettingsStoreImpl: DisplaySettingsStoreImpl): DisplaySettingsStore +} + +interface DisplaySettingsStore : SettingsStore { + /** + * Whether to wake the screen when an event occurs. + */ + val wakeScreen: SettingState + + /** + * Whether to hide the system bars. + */ + val hideSystemBars: SettingState +} + +@Singleton +class DisplaySettingsStoreImpl @Inject constructor(@param:ApplicationContext private val context: Context) : + DisplaySettingsStore, SettingsStoreImpl( + context = context, + default = DEFAULT, + fileName = "display_settings.json", + serializer = DisplaySettings.serializer() +) { + override val wakeScreen = SettingState(getFlow().map { it.wakeScreen }) { value -> + update { it.copy(wakeScreen = value) } + } + + override val hideSystemBars = SettingState(getFlow().map { it.hideSystemBars }) { value -> + update { it.copy(hideSystemBars = value) } + } +} diff --git a/app/src/main/java/com/example/ava/ui/screens/home/HomeScreen.kt b/app/src/main/java/com/example/ava/ui/screens/home/HomeScreen.kt index 385225c..174deb6 100644 --- a/app/src/main/java/com/example/ava/ui/screens/home/HomeScreen.kt +++ b/app/src/main/java/com/example/ava/ui/screens/home/HomeScreen.kt @@ -1,6 +1,7 @@ package com.example.ava.ui.screens.home import android.content.res.Configuration +import androidx.activity.compose.LocalActivity import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.PaddingValues @@ -30,20 +31,40 @@ import androidx.compose.ui.platform.LocalConfiguration import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp +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.ui.Settings +import com.example.ava.ui.screens.home.components.HideSystemBars +import com.example.ava.ui.screens.home.components.WakeScreenOnInteraction +import com.example.ava.ui.services.ServiceViewModel import com.example.ava.ui.services.StartStopVoiceSatellite import com.example.ava.ui.services.components.timerListSection import com.example.ava.ui.services.components.timerState @OptIn(ExperimentalMaterial3Api::class) @Composable -fun HomeScreen(navController: NavController) { - val configuration = LocalConfiguration.current - val isLandscape = configuration.orientation == Configuration.ORIENTATION_LANDSCAPE +fun HomeScreen( + navController: NavController, + viewModel: HomeViewModel = hiltViewModel(), + serviceViewModel: ServiceViewModel = hiltViewModel() +) { + val displaySettings by viewModel.displaySettings.collectAsStateWithLifecycle(null) + val satelliteState by serviceViewModel.satelliteState.collectAsStateWithLifecycle(null) + val timers by serviceViewModel.voiceTimers.collectAsStateWithLifecycle(emptyList()) + + val currentWindow = LocalActivity.current?.window + val isLandscape = LocalConfiguration.current.orientation == Configuration.ORIENTATION_LANDSCAPE val timerState = timerState() + if (displaySettings?.hideSystemBars == true && currentWindow != null) { + HideSystemBars(currentWindow) + } + if (displaySettings?.wakeScreen == true && currentWindow != null) { + WakeScreenOnInteraction(currentWindow, timers, satelliteState) + } + Scaffold( modifier = Modifier.fillMaxSize(), topBar = { diff --git a/app/src/main/java/com/example/ava/ui/screens/home/HomeViewModel.kt b/app/src/main/java/com/example/ava/ui/screens/home/HomeViewModel.kt new file mode 100644 index 0000000..0364cfc --- /dev/null +++ b/app/src/main/java/com/example/ava/ui/screens/home/HomeViewModel.kt @@ -0,0 +1,13 @@ +package com.example.ava.ui.screens.home + +import androidx.lifecycle.ViewModel +import com.example.ava.settings.DisplaySettingsStore +import dagger.hilt.android.lifecycle.HiltViewModel +import javax.inject.Inject + +@HiltViewModel +class HomeViewModel @Inject constructor( + displaySettingsStore: DisplaySettingsStore +) : ViewModel() { + val displaySettings = displaySettingsStore.getFlow() +} diff --git a/app/src/main/java/com/example/ava/ui/screens/home/components/HideSystemBars.kt b/app/src/main/java/com/example/ava/ui/screens/home/components/HideSystemBars.kt new file mode 100644 index 0000000..ab7af4e --- /dev/null +++ b/app/src/main/java/com/example/ava/ui/screens/home/components/HideSystemBars.kt @@ -0,0 +1,28 @@ +package com.example.ava.ui.screens.home.components + +import android.view.Window +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.core.view.WindowCompat +import androidx.core.view.WindowInsetsCompat +import androidx.core.view.WindowInsetsControllerCompat + +@Composable +fun HideSystemBars(window: Window) { + DisposableEffect(Unit) { + val insetsController = WindowCompat.getInsetsController(window, window.decorView) + + insetsController.apply { + hide(WindowInsetsCompat.Type.systemBars()) + systemBarsBehavior = + WindowInsetsControllerCompat.BEHAVIOR_SHOW_TRANSIENT_BARS_BY_SWIPE + } + + onDispose { + insetsController.apply { + show(WindowInsetsCompat.Type.systemBars()) + systemBarsBehavior = WindowInsetsControllerCompat.BEHAVIOR_DEFAULT + } + } + } +} diff --git a/app/src/main/java/com/example/ava/ui/screens/home/components/WakeScreenOnInteraction.kt b/app/src/main/java/com/example/ava/ui/screens/home/components/WakeScreenOnInteraction.kt new file mode 100644 index 0000000..b3760c7 --- /dev/null +++ b/app/src/main/java/com/example/ava/ui/screens/home/components/WakeScreenOnInteraction.kt @@ -0,0 +1,30 @@ +package com.example.ava.ui.screens.home.components + +import android.view.Window +import android.view.WindowManager +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.remember +import com.example.ava.esphome.EspHomeState +import com.example.ava.esphome.voicesatellite.Listening +import com.example.ava.esphome.voicesatellite.Processing +import com.example.ava.esphome.voicesatellite.Responding +import com.example.ava.esphome.voicesatellite.VoiceTimer +import kotlin.collections.contains + +private val wakingStates = setOf(Listening, Processing, Responding) + +@Composable +fun WakeScreenOnInteraction(window: Window, timers: List, satelliteState: EspHomeState?) { + val isInteracting = remember(timers, satelliteState) { + wakingStates.contains(satelliteState) || timers.any { it !is VoiceTimer.Paused } + } + if (isInteracting) { + DisposableEffect(Unit) { + window.addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON) + onDispose { + window.clearFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON) + } + } + } +} 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 f177ca2..aceb875 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 @@ -7,6 +7,7 @@ import androidx.compose.runtime.Immutable import androidx.core.net.toUri import androidx.lifecycle.ViewModel import com.example.ava.R +import com.example.ava.settings.DisplaySettingsStore import com.example.ava.settings.MicrophoneSettingsStore import com.example.ava.settings.PlayerSettingsStore import com.example.ava.settings.VoiceSatelliteSettingsStore @@ -33,7 +34,8 @@ class SettingsViewModel @Inject constructor( @param:ApplicationContext private val context: Context, private val satelliteSettingsStore: VoiceSatelliteSettingsStore, private val playerSettingsStore: PlayerSettingsStore, - private val microphoneSettingsStore: MicrophoneSettingsStore + private val microphoneSettingsStore: MicrophoneSettingsStore, + private val displaySettingsStore: DisplaySettingsStore ) : ViewModel() { val satelliteSettingsState = satelliteSettingsStore.getFlow() @@ -56,6 +58,8 @@ class SettingsViewModel @Inject constructor( val playerSettingsState = playerSettingsStore.getFlow() + val displaySettingsState = displaySettingsStore.getFlow() + suspend fun saveName(name: String) { if (validateName(name).isNullOrBlank()) { satelliteSettingsStore.name.set(name) @@ -148,6 +152,14 @@ class SettingsViewModel @Inject constructor( playerSettingsStore.repeatTimerFinishedSound.set(repeatTimerFinishedSound) } + suspend fun saveWakeScreen(wakeScreen: Boolean) { + displaySettingsStore.wakeScreen.set(wakeScreen) + } + + suspend fun saveHideSystemBars(hideSystemBars: Boolean) { + displaySettingsStore.hideSystemBars.set(hideSystemBars) + } + fun validateName(name: String): String? = if (name.isBlank()) context.getString(R.string.validation_voice_satellite_name_empty) @@ -167,4 +179,4 @@ class SettingsViewModel @Inject constructor( 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 index 8c89bea..d9a355a 100644 --- 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 @@ -30,6 +30,7 @@ fun VoiceSatelliteSettings( val satelliteState by viewModel.satelliteSettingsState.collectAsStateWithLifecycle(null) val microphoneState by viewModel.microphoneSettingsState.collectAsStateWithLifecycle(null) val playerState by viewModel.playerSettingsState.collectAsStateWithLifecycle(null) + val displayState by viewModel.displaySettingsState.collectAsStateWithLifecycle(null) val disabledLabel = stringResource(R.string.label_disabled) LazyColumn( @@ -202,5 +203,34 @@ fun VoiceSatelliteSettings( } ) } + item { + HorizontalDivider() + } + item { + SwitchSetting( + name = stringResource(R.string.label_wake_screen), + description = stringResource(R.string.description_wake_screen), + value = displayState?.wakeScreen ?: false, + enabled = enabled, + onCheckedChange = { + coroutineScope.launch { + viewModel.saveWakeScreen(it) + } + } + ) + } + item { + SwitchSetting( + name = stringResource(R.string.label_hide_system_bars), + description = stringResource(R.string.description_hide_system_bars), + value = displayState?.hideSystemBars ?: false, + enabled = enabled, + onCheckedChange = { + coroutineScope.launch { + viewModel.saveHideSystemBars(it) + } + } + ) + } } -} \ No newline at end of file +} diff --git a/app/src/main/java/com/example/ava/ui/services/ServiceViewModel.kt b/app/src/main/java/com/example/ava/ui/services/ServiceViewModel.kt index 155e758..f3c9ef7 100644 --- a/app/src/main/java/com/example/ava/ui/services/ServiceViewModel.kt +++ b/app/src/main/java/com/example/ava/ui/services/ServiceViewModel.kt @@ -24,7 +24,7 @@ import javax.inject.Inject @HiltViewModel class ServiceViewModel @Inject constructor( @param:ApplicationContext private val context: Context, - private val settings: VoiceSatelliteSettingsStore + private val settings: VoiceSatelliteSettingsStore, ) : ViewModel() { private var created = false @@ -35,6 +35,10 @@ class ServiceViewModel @Inject constructor( service?.voiceTimers ?: flowOf(emptyList()) } + val satelliteState = _satellite.flatMapLatest { service -> + service?.voiceSatelliteState ?: flowOf(null) + } + private val serviceConnection = bindService(context) { _satellite.value = it } @@ -80,4 +84,4 @@ class ServiceViewModel @Inject constructor( _satellite.dropWhile { it == null }.first()?.startVoiceSatellite() } } -} \ No newline at end of file +} diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index fbc365c..973cf35 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -22,6 +22,10 @@ Specify a different file to play Repeat timer sound If enabled, repeat the sound until you say "stop" + Wake screen on activity + Turn on the screen when a wake word is detected or timers are active + Hide system bars + Run in full screen mode by hiding status and navigation bars Name cannot be empty Port must be between 1 and 65535