Skip to content

Commit

Permalink
Use ViewState instead of ConfigurationVO
Browse files Browse the repository at this point in the history
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.
  • Loading branch information
jonreeve committed Sep 1, 2022
1 parent 72b8ea6 commit 9632fb5
Show file tree
Hide file tree
Showing 3 changed files with 133 additions and 69 deletions.
138 changes: 89 additions & 49 deletions app/src/main/java/com/wasabicode/notifyxso/app/ConfigurationUi.kt
Original file line number Diff line number Diff line change
Expand Up @@ -10,65 +10,91 @@ 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
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<String>) -> 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)
}
}
}
}

@Composable
private fun ForwardingSwitch(
config: Configuration,
enabled: Boolean,
onForwardingChanged: (Boolean) -> Unit
) {
Text(
Expand All @@ -77,7 +103,7 @@ private fun ForwardingSwitch(
modifier = Modifier.fillMaxWidth()
)
Switch(
checked = config.enabled,
checked = enabled,
onCheckedChange = onForwardingChanged,
modifier = Modifier.fillMaxWidth()
)
Expand All @@ -93,35 +119,40 @@ 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)
)
}
}

@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()
Expand All @@ -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(
Expand Down Expand Up @@ -180,14 +211,14 @@ private fun IconDropDown(config: Configuration, onSelected: (PreferredIcon) -> U

@Composable
private fun FilterConfig(
config: Configuration,
onExclusionsChanged: (exclusions: Set<String>) -> 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) {
Expand Down Expand Up @@ -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)
}
19 changes: 11 additions & 8 deletions app/src/main/java/com/wasabicode/notifyxso/app/MainActivity.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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))
Expand All @@ -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,
Expand All @@ -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) {
Expand All @@ -82,7 +85,7 @@ class MainActivity : ComponentActivity() {
viewModel.input(UpdateIcon(icon))
}

private fun onExclusionsChanged(exclusions: Set<String>) {
private fun onExclusionsChanged(exclusions: String) {
viewModel.input(UpdateExclusions(exclusions))
}

Expand Down
45 changes: 33 additions & 12 deletions app/src/main/java/com/wasabicode/notifyxso/app/MainViewModel.kt
Original file line number Diff line number Diff line change
Expand Up @@ -4,49 +4,70 @@ 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
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> = _state
private val _viewState = MutableStateFlow<ViewState>(ViewState.Loading)
val viewState: StateFlow<ViewState> = _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<String>) : Intention
data class UpdateExclusions(val exclusions: String) : Intention
}
}

0 comments on commit 9632fb5

Please sign in to comment.