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:
39
android/app/src/main/java/org/musicpd/ui/NetworkAddress.kt
Normal file
39
android/app/src/main/java/org/musicpd/ui/NetworkAddress.kt
Normal 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 ?: "")
|
||||
}
|
||||
}
|
||||
|
||||
52
android/app/src/main/java/org/musicpd/ui/Preferences.kt
Normal file
52
android/app/src/main/java/org/musicpd/ui/Preferences.kt
Normal 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
|
||||
)
|
||||
|
||||
}
|
||||
212
android/app/src/main/java/org/musicpd/ui/SettingsActivity.kt
Normal file
212
android/app/src/main/java/org/musicpd/ui/SettingsActivity.kt
Normal 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()
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user