From 9632fb57bd9069e039c65502748f2902ddfeb64a Mon Sep 17 00:00:00 2001 From: Jon Reeve Date: Thu, 1 Sep 2022 22:15:37 +0100 Subject: [PATCH] Use ViewState instead of ConfigurationVO Now that the view layer has no implicit state, need to explicitly retain the state somewhere. Where we could previously use a slightly more detail-agnostic ViewState and keep rendering detail in the view layer, doing so now would require re-introducing local state in the Composables and comparing input to it, knowing which to keep. Yuk. So instead the ViewState has to become very tightly bound to the UI impl. details, to actually keep their state, such as text entry for a decimal number. That kinda sucks. I'm probably just looking at it wrong, fighting it and doing it the wrong way, based on previous experience. I'm sure different seams exist here instead like with React. Maybe should emit the Compose UI straight out of the VM or something. Don't know yet. --- .../notifyxso/app/ConfigurationUi.kt | 138 +++++++++++------- .../wasabicode/notifyxso/app/MainActivity.kt | 19 ++- .../wasabicode/notifyxso/app/MainViewModel.kt | 45 ++++-- 3 files changed, 133 insertions(+), 69 deletions(-) diff --git a/app/src/main/java/com/wasabicode/notifyxso/app/ConfigurationUi.kt b/app/src/main/java/com/wasabicode/notifyxso/app/ConfigurationUi.kt index 01785be..004113a 100644 --- a/app/src/main/java/com/wasabicode/notifyxso/app/ConfigurationUi.kt +++ b/app/src/main/java/com/wasabicode/notifyxso/app/ConfigurationUi.kt @@ -10,7 +10,6 @@ import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.ArrowDropDown import androidx.compose.runtime.* import androidx.compose.ui.Alignment -import androidx.compose.ui.Alignment.Companion.CenterHorizontally import androidx.compose.ui.Modifier import androidx.compose.ui.text.intl.Locale import androidx.compose.ui.text.style.TextAlign @@ -18,49 +17,76 @@ import androidx.compose.ui.text.toUpperCase import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import com.google.android.material.composethemeadapter.MdcTheme -import com.wasabicode.notifyxso.app.config.Configuration -import com.wasabicode.notifyxso.app.config.ConfigurationVO +import com.wasabicode.notifyxso.app.MainViewModel.ViewState import com.wasabicode.notifyxso.app.config.PreferredIcon import com.wasabicode.notifyxso.app.config.Server import java.text.DecimalFormat @Composable fun ConfigurationUi( - config: Configuration?, + viewState: ViewState, onForwardingChanged: (Boolean) -> Unit = {}, - onServerChanged: (server: Server) -> Unit = {}, + onServerChanged: (host: String, port: String) -> Unit = { _, _ -> }, onDurationChanged: (durationSecs: Float) -> Unit = {}, onIconChanged: (icon: PreferredIcon) -> Unit = {}, - onExclusionsChanged: (exclusions: Set) -> Unit = {}, + onExclusionsChanged: (exclusions: String) -> Unit = {}, onTestNotificationButtonClicked: () -> Unit = {}, onPermissionButtonClicked: () -> Unit = {} ) { - if (config == null) { - MdcTheme { - CircularProgressIndicator(Modifier.padding(64.dp)) - } - } else { - MdcTheme { - Box( - modifier = Modifier + when (viewState) { + is ViewState.Loading -> LoadingUi() + is ViewState.NoPermission -> LoadingUi() // TODO + is ViewState.Content -> ContentUi( + viewState, + onForwardingChanged, + onServerChanged, + onDurationChanged, + onIconChanged, + onExclusionsChanged, + onTestNotificationButtonClicked, + onPermissionButtonClicked, + ) + } +} + +@Composable +private fun LoadingUi() { + MdcTheme { + CircularProgressIndicator(Modifier.padding(64.dp)) + } +} + +@Composable +private fun ContentUi( + viewState: ViewState.Content, + onForwardingChanged: (Boolean) -> Unit = {}, + onServerChanged: (host: String, port: String) -> Unit = { _, _ -> }, + onDurationChanged: (durationSecs: Float) -> Unit = {}, + onIconChanged: (icon: PreferredIcon) -> Unit = {}, + onExclusionsChanged: (exclusions: String) -> Unit = {}, + onTestNotificationButtonClicked: () -> Unit = {}, + onPermissionButtonClicked: () -> Unit = {} +) { + MdcTheme { + Box( + modifier = Modifier + .fillMaxWidth() + .verticalScroll(rememberScrollState()) + ) + { + Column( + Modifier .fillMaxWidth() - .verticalScroll(rememberScrollState()) - ) - { - Column( - Modifier - .fillMaxWidth() - .padding(16.dp) - ) { - ForwardingSwitch(config, onForwardingChanged) - SectionHeader("Server") - ServerConfig(config, onServerChanged) - SectionHeader("Appearance") - AppearanceConfig(config, onDurationChanged, onIconChanged) - SectionHeader("Filter") - FilterConfig(config, onExclusionsChanged) - Buttons(onTestNotificationButtonClicked, onPermissionButtonClicked) - } + .padding(16.dp) + ) { + ForwardingSwitch(viewState.enabled, onForwardingChanged) + SectionHeader("Server") + ServerConfig(viewState.host, viewState.port, onServerChanged) + SectionHeader("Appearance") + AppearanceConfig(viewState.duration, viewState.icon, onDurationChanged, onIconChanged) + SectionHeader("Filter") + FilterConfig(viewState.exclusions, onExclusionsChanged) + Buttons(onTestNotificationButtonClicked, onPermissionButtonClicked) } } } @@ -68,7 +94,7 @@ fun ConfigurationUi( @Composable private fun ForwardingSwitch( - config: Configuration, + enabled: Boolean, onForwardingChanged: (Boolean) -> Unit ) { Text( @@ -77,7 +103,7 @@ private fun ForwardingSwitch( modifier = Modifier.fillMaxWidth() ) Switch( - checked = config.enabled, + checked = enabled, onCheckedChange = onForwardingChanged, modifier = Modifier.fillMaxWidth() ) @@ -93,20 +119,24 @@ fun SectionHeader(text: String) { } @Composable -private fun ServerConfig(config: Configuration, onChange: (server: Server) -> Unit) { +private fun ServerConfig( + host: String, + port: String, + onChange: (host: String, port: String) -> Unit +) { Row { TextField( - value = config.server.host, + value = host, label = { Text("Host") }, - onValueChange = { onChange(config.server.copy(host = it)) }, + onValueChange = { onChange(it, port) }, modifier = Modifier .weight(3f) .padding(end = 4.dp) ) TextField( - value = config.server.port.toString(), + value = port, label = { Text("Port") }, - onValueChange = { onChange(config.server.copy(port = it.toIntOrNull() ?: 0)) }, + onValueChange = { onChange(host, it) }, modifier = Modifier.weight(1f) ) } @@ -114,14 +144,15 @@ private fun ServerConfig(config: Configuration, onChange: (server: Server) -> Un @Composable fun AppearanceConfig( - config: Configuration, + duration: String, + icon: PreferredIcon, onDurationChanged: (durationSecs: Float) -> Unit, onIconChanged: (icon: PreferredIcon) -> Unit ) { val decimalFormat = DecimalFormat.getNumberInstance() Row(verticalAlignment = Alignment.CenterVertically) { TextField( - value = decimalFormat.format(config.durationSecs), + value = duration, label = { Text("Duration (secs)") }, onValueChange = { text -> val newDuration = runCatching { decimalFormat.parse(text) }.getOrNull()?.toFloat() @@ -142,15 +173,15 @@ fun AppearanceConfig( modifier = Modifier.padding(horizontal = 4.dp) ) } - IconDropDown(config, onSelected = onIconChanged) + IconDropDown(icon, onSelected = onIconChanged) } } } @Composable -private fun IconDropDown(config: Configuration, onSelected: (PreferredIcon) -> Unit) { +private fun IconDropDown(icon: PreferredIcon, onSelected: (PreferredIcon) -> Unit) { val icons = PreferredIcon.values() - val selected = config.preferredIcon.name + val selected = icon.name var expanded by remember { mutableStateOf(false) } Row( @@ -180,14 +211,14 @@ private fun IconDropDown(config: Configuration, onSelected: (PreferredIcon) -> U @Composable private fun FilterConfig( - config: Configuration, - onExclusionsChanged: (exclusions: Set) -> Unit, + exclusions: String, + onExclusionsChanged: (exclusions: String) -> Unit, modifier: Modifier = Modifier ) { TextField( - value = config.exclusions.joinToString(separator = "\n"), + value = exclusions, label = { Text("Exclusions") }, - onValueChange = { onExclusionsChanged(it.lines().toSet()) }, + onValueChange = { onExclusionsChanged(it) }, modifier = Modifier.fillMaxWidth() ) CompositionLocalProvider(LocalContentAlpha provides ContentAlpha.medium) { @@ -221,11 +252,20 @@ private fun Buttons(onTestNotificationButtonClicked: () -> Unit, onPermissionBut @Preview(name = "Loaded", widthDp = 320, heightDp = 700) @Composable private fun PreviewLoaded() { - ConfigurationUi(config = ConfigurationVO()) + ConfigurationUi( + viewState = ViewState.Content( + enabled = false, + host = "192.,168.16.8", + port = "43210", + duration = "2", + icon = PreferredIcon.Default, + exclusions = "" + ) + ) } @Preview(name = "Loading", widthDp = 320, heightDp = 160) @Composable private fun PreviewLoading() { - ConfigurationUi(config = null) + ConfigurationUi(viewState = ViewState.Loading) } diff --git a/app/src/main/java/com/wasabicode/notifyxso/app/MainActivity.kt b/app/src/main/java/com/wasabicode/notifyxso/app/MainActivity.kt index a91ede4..b50c77b 100644 --- a/app/src/main/java/com/wasabicode/notifyxso/app/MainActivity.kt +++ b/app/src/main/java/com/wasabicode/notifyxso/app/MainActivity.kt @@ -4,8 +4,6 @@ import android.content.Intent import android.os.Bundle import androidx.activity.ComponentActivity import androidx.activity.compose.setContent -import androidx.appcompat.app.AppCompatActivity -import androidx.compose.ui.platform.ComposeView import androidx.lifecycle.Lifecycle import androidx.lifecycle.lifecycleScope import androidx.lifecycle.repeatOnLifecycle @@ -38,7 +36,12 @@ class MainActivity : ComponentActivity() { private fun processArgs() { if (hostArg != null || portArg != null) { - viewModel.input(UpdateServer(Server(host = hostArg ?: config.server.host, port = portArg ?: config.server.port))) + viewModel.input( + UpdateServer( + host = hostArg ?: config.server.host, + port = portArg?.toString() ?: config.server.port.toString() + ) + ) } if (enableOnStartArg) { viewModel.input(UpdateForwardingEnabled(true)) @@ -48,10 +51,10 @@ class MainActivity : ComponentActivity() { private fun observeViewModel() { lifecycleScope.launch { repeatOnLifecycle(Lifecycle.State.STARTED) { - viewModel.state.collectLatest { state -> + viewModel.viewState.collectLatest { state -> setContent { ConfigurationUi( - config = state.configuration, + viewState = state, onForwardingChanged = ::onForwardingChanged, onServerChanged = ::onServerChanged, onDurationChanged = ::onDurationChanged, @@ -70,8 +73,8 @@ class MainActivity : ComponentActivity() { viewModel.input(UpdateForwardingEnabled(enabled)) } - private fun onServerChanged(server: Server) { - viewModel.input(UpdateServer(server)) + private fun onServerChanged(host: String, port: String) { + viewModel.input(UpdateServer(host, port)) } private fun onDurationChanged(durationSecs: Float) { @@ -82,7 +85,7 @@ class MainActivity : ComponentActivity() { viewModel.input(UpdateIcon(icon)) } - private fun onExclusionsChanged(exclusions: Set) { + private fun onExclusionsChanged(exclusions: String) { viewModel.input(UpdateExclusions(exclusions)) } diff --git a/app/src/main/java/com/wasabicode/notifyxso/app/MainViewModel.kt b/app/src/main/java/com/wasabicode/notifyxso/app/MainViewModel.kt index 234af6f..60f250f 100644 --- a/app/src/main/java/com/wasabicode/notifyxso/app/MainViewModel.kt +++ b/app/src/main/java/com/wasabicode/notifyxso/app/MainViewModel.kt @@ -4,7 +4,6 @@ import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.wasabicode.notifyxso.app.MainViewModel.Intention.* import com.wasabicode.notifyxso.app.config.Configuration -import com.wasabicode.notifyxso.app.config.ConfigurationVO import com.wasabicode.notifyxso.app.config.PreferredIcon import com.wasabicode.notifyxso.app.config.Server import kotlinx.coroutines.CoroutineDispatcher @@ -12,41 +11,63 @@ import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.launch +import java.text.DecimalFormat +import java.text.NumberFormat class MainViewModel(private val app: App, private val ioDispatcher: CoroutineDispatcher = Dispatchers.IO) : ViewModel() { - private val _state = MutableStateFlow(State()) - val state: StateFlow = _state + private val _viewState = MutableStateFlow(ViewState.Loading) + val viewState: StateFlow = _viewState + + private val decimalFormat = DecimalFormat.getNumberInstance() init { viewModelScope.launch(ioDispatcher) { - _state.value = State(ConfigurationVO(app.configuration)) + _viewState.value = ViewState.Content(app.configuration, decimalFormat) } } fun input(intention: Intention) = viewModelScope.launch(ioDispatcher) { when (intention) { is UpdateForwardingEnabled -> updateConfig { enabled = intention.enabled } - is UpdateServer -> updateConfig { server = intention.server } + is UpdateServer -> updateConfig { server = Server(intention.host, intention.port.toIntOrNull() ?: 0) } is UpdateDuration -> updateConfig { durationSecs = intention.durationSecs } is UpdateIcon -> updateConfig { preferredIcon = intention.preferredIcon } - is UpdateExclusions -> updateConfig { exclusions = intention.exclusions } + is UpdateExclusions -> updateConfig { exclusions = intention.exclusions.lines().toSet() } } } private fun updateConfig(update: Configuration.() -> Unit) { app.configuration.update() - _state.value = State(ConfigurationVO(app.configuration)) + _viewState.value = ViewState.Content(app.configuration, decimalFormat) } - data class State( - val configuration: Configuration? = null - ) + sealed interface ViewState { + object NoPermission : ViewState + object Loading : ViewState + data class Content( + val enabled: Boolean, + val host: String, + val port: String, + val duration: String, + val icon: PreferredIcon, + val exclusions: String + ) : ViewState { + constructor(config: Configuration, decimalFormat: NumberFormat) : this( + enabled = config.enabled, + host = config.server.host, + port = config.server.port.toString(), + duration = decimalFormat.format(config.durationSecs), + exclusions = config.exclusions.joinToString(separator = "\n"), + icon = config.preferredIcon + ) + } + } sealed interface Intention { data class UpdateForwardingEnabled(val enabled: Boolean) : Intention - data class UpdateServer(val server: Server) : Intention + data class UpdateServer(val host: String, val port: String) : Intention data class UpdateDuration(val durationSecs: Float) : Intention data class UpdateIcon(val preferredIcon: PreferredIcon) : Intention - data class UpdateExclusions(val exclusions: Set) : Intention + data class UpdateExclusions(val exclusions: String) : Intention } }