android: Move settings to compose and material UI 3

This changes lets us use the latest UI design from Google, Material 3.

Google only provides the material UI 3 themes for compose, compose only works with kotlin.
This commit is contained in:
Colin Edwards
2023-12-22 22:52:33 -06:00
parent 625ab6decd
commit ddc048e2c3
13 changed files with 410 additions and 340 deletions

View File

@@ -0,0 +1,39 @@
package org.musicpd.ui
import android.app.Application
import androidx.compose.foundation.layout.Arrangement
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.size
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Wifi
import androidx.compose.material3.Icon
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.unit.dp
import org.musicpd.NetworkUtil
@Composable
fun NetworkAddress() {
val address = NetworkUtil.getDeviceIPV4Address(LocalContext.current)
val padding = 4.dp
Row(
Modifier
.padding(padding)
.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.Center
) {
Icon(
imageVector = Icons.Default.Wifi,
contentDescription = "Wifi")
Spacer(Modifier.size(padding))
Text(text = address ?: "")
}
}

View File

@@ -0,0 +1,52 @@
package org.musicpd.ui
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.BatteryAlert
import androidx.compose.material.icons.filled.Headphones
import androidx.compose.material.icons.filled.PowerSettingsNew
import androidx.compose.material3.Icon
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.res.stringResource
import com.alorma.compose.settings.storage.preferences.rememberPreferenceBooleanSettingState
import com.alorma.compose.settings.ui.SettingsSwitch
import org.musicpd.Preferences
import org.musicpd.R
@Composable
fun SettingsOptions(
onBootChanged: (Boolean) -> Unit,
onWakeLockChanged: (Boolean) -> Unit,
onHeadphonesChanged: (Boolean) -> Unit
) {
val bootState = rememberPreferenceBooleanSettingState(
key = Preferences.KEY_RUN_ON_BOOT,
defaultValue = false
)
val wakelockState =
rememberPreferenceBooleanSettingState(key = Preferences.KEY_WAKELOCK, defaultValue = false)
val headphoneState = rememberPreferenceBooleanSettingState(
key = Preferences.KEY_PAUSE_ON_HEADPHONES_DISCONNECT,
defaultValue = false
)
SettingsSwitch(
icon = { Icon(imageVector = Icons.Default.PowerSettingsNew, contentDescription = "Power") },
title = { Text(text = stringResource(R.string.checkbox_run_on_boot)) },
onCheckedChange = onBootChanged,
state = bootState
)
SettingsSwitch(
icon = { Icon(imageVector = Icons.Default.BatteryAlert, contentDescription = "Battery") },
title = { Text(text = stringResource(R.string.checkbox_wakelock)) },
onCheckedChange = onWakeLockChanged,
state = wakelockState
)
SettingsSwitch(
icon = { Icon(imageVector = Icons.Default.Headphones, contentDescription = "Headphones") },
title = { Text(text = stringResource(R.string.checkbox_pause_on_headphones_disconnect)) },
onCheckedChange = onHeadphonesChanged,
state = headphoneState
)
}

View File

@@ -0,0 +1,212 @@
package org.musicpd.ui
import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.activity.viewModels
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Circle
import androidx.compose.material3.Button
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.State
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.viewmodel.compose.viewModel
import com.google.accompanist.permissions.ExperimentalPermissionsApi
import com.google.accompanist.permissions.isGranted
import com.google.accompanist.permissions.rememberPermissionState
import com.google.accompanist.permissions.shouldShowRationale
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import org.musicpd.Main
import org.musicpd.R
class SettingsActivity : ComponentActivity() {
private val settingsViewModel: SettingsViewModel by viewModels()
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContent {
MaterialTheme {
SettingsContainer(settingsViewModel)
}
}
}
private fun connectClient() {
val client = Main.Client(this, object : Main.Client.Callback {
override fun onStopped() {
settingsViewModel.updateStatus("", false)
}
override fun onStarted() {
settingsViewModel.updateStatus("MPD Service Started", true)
}
override fun onError(error: String) {
settingsViewModel.removeClient()
settingsViewModel.updateStatus(error, false)
connectClient()
}
override fun onLog(priority: Int, msg: String) {
settingsViewModel.addLogItem(priority, msg)
}
})
settingsViewModel.setClient(client)
}
override fun onStart() {
//mFirstRun = false
connectClient()
super.onStart()
}
override fun onStop() {
settingsViewModel.removeClient()
super.onStop()
}
}
@OptIn(ExperimentalPermissionsApi::class)
@Composable
fun SettingsContainer(settingsViewModel: SettingsViewModel = viewModel()) {
val context = LocalContext.current
val storagePermissionState = rememberPermissionState(
android.Manifest.permission.READ_EXTERNAL_STORAGE
)
if (storagePermissionState.status.shouldShowRationale) {
Column(Modifier
.padding(4.dp)
.fillMaxWidth(),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center
) {
Text(stringResource(id = R.string.external_files_permission_request))
Button(onClick = { }) {
Text("Request permission")
}
}
} else {
Column {
NetworkAddress()
ServerStatus(settingsViewModel)
if (!storagePermissionState.status.isGranted) {
OutlinedButton(onClick = { storagePermissionState.launchPermissionRequest() }, Modifier
.padding(4.dp)
.fillMaxWidth()) {
Text("Request external storage permission", color = MaterialTheme.colorScheme.secondary)
}
}
SettingsOptions(
onBootChanged = { newValue ->
if (newValue) {
settingsViewModel.startMPD(context)
}
},
onWakeLockChanged = { newValue ->
settingsViewModel.setWakelockEnabled(newValue)
},
onHeadphonesChanged = { newValue ->
settingsViewModel.setPauseOnHeadphonesDisconnect(newValue)
}
)
LogView(settingsViewModel.logItemFLow.collectAsStateWithLifecycle())
}
}
}
@Composable
fun ServerStatus(settingsViewModel: SettingsViewModel) {
val context = LocalContext.current
val statusUiState by settingsViewModel.statusUIState.collectAsState()
Column {
Row(
Modifier
.padding(4.dp)
.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceEvenly
) {
Row {
Icon(
imageVector = Icons.Default.Circle,
contentDescription = "",
tint = if (statusUiState.running) Color(0xFFB8F397) else Color(0xFFFFDAD6)
)
Text(text = if (statusUiState.running) "Running" else "Stopped")
}
Button(onClick = {
if (statusUiState.running)
settingsViewModel.stopMPD()
else
settingsViewModel.startMPD(context)
}) {
Text(text = if (statusUiState.running) "Stop MPD" else "Start MPD")
}
}
Row(
Modifier
.padding(4.dp)
.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceEvenly
) {
Text(text = statusUiState.statusMessage)
}
}
}
@Composable
fun LogView(messages: State<List<String>>) {
val state = rememberLazyListState()
LazyColumn(
Modifier.padding(4.dp),
state
) {
items(messages.value) { message ->
Text(text = message, fontFamily = FontFamily.Monospace)
}
CoroutineScope(Dispatchers.Main).launch {
state.scrollToItem(messages.value.count(), 0)
}
}
}
@Preview(showBackground = true)
@Composable
fun SettingsPreview() {
MaterialTheme {
SettingsContainer()
}
}

View File

@@ -0,0 +1,84 @@
package org.musicpd.ui
import android.content.Context
import android.util.Log
import androidx.lifecycle.ViewModel
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import org.musicpd.Main
import org.musicpd.Preferences
private const val MAX_LOGS = 500
class SettingsViewModel : ViewModel() {
private var mClient: Main.Client? = null
private val _logItemFLow = MutableStateFlow(listOf<String>())
val logItemFLow: StateFlow<List<String>> = _logItemFLow
data class StatusUiState(
val statusMessage: String = "",
val running: Boolean = false
)
private val _statusUIState = MutableStateFlow(StatusUiState())
val statusUIState: StateFlow<StatusUiState> = _statusUIState.asStateFlow()
fun addLogItem(priority: Int, message: String) {
if (_logItemFLow.value.size > MAX_LOGS) {
_logItemFLow.value = _logItemFLow.value.drop(1)
}
val priorityString: String = when (priority) {
Log.DEBUG -> "D"
Log.ERROR -> "E"
Log.INFO -> "I"
Log.VERBOSE -> "V"
Log.WARN -> "W"
else -> ""
}
_logItemFLow.value = _logItemFLow.value + ("$priorityString/$message")
}
fun updateStatus(message: String, running: Boolean) {
_statusUIState.value = StatusUiState(message, running)
}
fun setClient(client: Main.Client) {
mClient = client
}
fun removeClient() {
mClient?.release()
mClient = null
}
fun startMPD(context: Context) {
mClient?.start()
if (Preferences.getBoolean(
context,
Preferences.KEY_WAKELOCK, false
)
) mClient?.setWakelockEnabled(true)
if (Preferences.getBoolean(
context,
Preferences.KEY_PAUSE_ON_HEADPHONES_DISCONNECT, false
)
) mClient?.setPauseOnHeadphonesDisconnect(true)
}
fun stopMPD() {
mClient?.stop()
}
fun setWakelockEnabled(enabled: Boolean) {
mClient?.setWakelockEnabled(enabled)
}
fun setPauseOnHeadphonesDisconnect(enabled: Boolean) {
mClient?.setPauseOnHeadphonesDisconnect(enabled)
}
}