From 5b7de2bc2f012ef72724f2c9d0d8c34f3a5dadf2 Mon Sep 17 00:00:00 2001 From: Colin Edwards Date: Thu, 18 Jan 2024 01:20:37 -0600 Subject: [PATCH] android: Refactor settings UI into screens and add a bottom bar. This puts Status, logs, and settings all on different tabs. This gives us plenty more room to work to improve these views going forward --- android/app/build.gradle.kts | 1 + android/app/src/main/AndroidManifest.xml | 2 +- .../app/src/main/java/org/musicpd/Main.java | 3 +- .../src/main/java/org/musicpd/MainActivity.kt | 57 +++++ .../src/main/java/org/musicpd/ui/LogScreen.kt | 36 +++ .../main/java/org/musicpd/ui/MainScreen.kt | 112 ++++++++++ .../java/org/musicpd/ui/SettingsActivity.kt | 210 ------------------ .../ui/{Preferences.kt => SettingsScreen.kt} | 30 ++- .../main/java/org/musicpd/ui/StatusScreen.kt | 119 ++++++++++ 9 files changed, 354 insertions(+), 216 deletions(-) create mode 100644 android/app/src/main/java/org/musicpd/MainActivity.kt create mode 100644 android/app/src/main/java/org/musicpd/ui/LogScreen.kt create mode 100644 android/app/src/main/java/org/musicpd/ui/MainScreen.kt delete mode 100644 android/app/src/main/java/org/musicpd/ui/SettingsActivity.kt rename android/app/src/main/java/org/musicpd/ui/{Preferences.kt => SettingsScreen.kt} (70%) create mode 100644 android/app/src/main/java/org/musicpd/ui/StatusScreen.kt diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts index 0145aebac..99aec3723 100644 --- a/android/app/build.gradle.kts +++ b/android/app/build.gradle.kts @@ -61,6 +61,7 @@ dependencies { implementation("androidx.compose.material:material-icons-extended") implementation("androidx.lifecycle:lifecycle-viewmodel-compose:2.6.2") implementation("androidx.lifecycle:lifecycle-runtime-compose:2.6.2") + implementation("androidx.navigation:navigation-compose:2.7.6") implementation("com.github.alorma:compose-settings-ui-m3:1.0.3") implementation("com.github.alorma:compose-settings-storage-preferences:1.0.3") diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index facfd24ca..55ad59448 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -28,7 +28,7 @@ android:theme="@style/Theme.MPD" android:name=".MPDApplication"> diff --git a/android/app/src/main/java/org/musicpd/Main.java b/android/app/src/main/java/org/musicpd/Main.java index 25827b53e..96a87934e 100644 --- a/android/app/src/main/java/org/musicpd/Main.java +++ b/android/app/src/main/java/org/musicpd/Main.java @@ -27,7 +27,6 @@ import androidx.media3.common.util.UnstableApi; import androidx.media3.session.MediaSession; import org.musicpd.data.LoggingRepository; -import org.musicpd.ui.SettingsActivity; import java.lang.reflect.Constructor; import java.lang.reflect.Method; @@ -208,7 +207,7 @@ public class Main extends Service implements Runnable { } }, filter); - final Intent mainIntent = new Intent(this, SettingsActivity.class); + final Intent mainIntent = new Intent(this, MainActivity.class); mainIntent.setAction("android.intent.action.MAIN"); mainIntent.addCategory("android.intent.category.LAUNCHER"); final PendingIntent contentIntent = PendingIntent.getActivity(this, 0, diff --git a/android/app/src/main/java/org/musicpd/MainActivity.kt b/android/app/src/main/java/org/musicpd/MainActivity.kt new file mode 100644 index 000000000..87965e613 --- /dev/null +++ b/android/app/src/main/java/org/musicpd/MainActivity.kt @@ -0,0 +1,57 @@ +package org.musicpd + +import android.os.Bundle +import androidx.activity.ComponentActivity +import androidx.activity.compose.setContent +import androidx.activity.viewModels +import androidx.compose.material3.MaterialTheme +import androidx.core.view.WindowCompat +import dagger.hilt.android.AndroidEntryPoint +import org.musicpd.ui.MPDApp +import org.musicpd.ui.SettingsViewModel + +@AndroidEntryPoint +class MainActivity : ComponentActivity() { + private val settingsViewModel: SettingsViewModel by viewModels() + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + WindowCompat.setDecorFitsSystemWindows(window, false) + setContent { + MaterialTheme { + MPDApp(settingsViewModel = 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() + } + }) + + settingsViewModel.setClient(client) + } + + override fun onStart() { + //mFirstRun = false + connectClient() + super.onStart() + } + + override fun onStop() { + settingsViewModel.removeClient() + super.onStop() + } +} diff --git a/android/app/src/main/java/org/musicpd/ui/LogScreen.kt b/android/app/src/main/java/org/musicpd/ui/LogScreen.kt new file mode 100644 index 000000000..8a1c14086 --- /dev/null +++ b/android/app/src/main/java/org/musicpd/ui/LogScreen.kt @@ -0,0 +1,36 @@ +package org.musicpd.ui + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +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.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.State +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.unit.dp +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch + +@Composable +fun LogView(messages: State>) { + val state = rememberLazyListState() + + Column(Modifier.fillMaxSize()) { + 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) + } + } + } +} \ No newline at end of file diff --git a/android/app/src/main/java/org/musicpd/ui/MainScreen.kt b/android/app/src/main/java/org/musicpd/ui/MainScreen.kt new file mode 100644 index 000000000..54be709b1 --- /dev/null +++ b/android/app/src/main/java/org/musicpd/ui/MainScreen.kt @@ -0,0 +1,112 @@ +package org.musicpd.ui + +import MPDSettings +import android.graphics.drawable.Icon +import androidx.compose.foundation.layout.padding +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Circle +import androidx.compose.material.icons.filled.Home +import androidx.compose.material.icons.filled.List +import androidx.compose.material.icons.filled.Settings +import androidx.compose.material3.Icon +import androidx.compose.material3.NavigationBar +import androidx.compose.material3.NavigationBarItem +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import androidx.lifecycle.viewmodel.compose.viewModel +import androidx.navigation.NavController +import androidx.navigation.NavHostController +import androidx.navigation.compose.NavHost +import androidx.navigation.compose.composable +import androidx.navigation.compose.currentBackStackEntryAsState +import androidx.navigation.compose.rememberNavController + +enum class Screen { + HOME, + LOGS, + SETTINGS, +} +sealed class NavigationItem(val route: String, val label: String, val icon: ImageVector) { + data object Home : NavigationItem( + Screen.HOME.name, + "Home", + Icons.Default.Home + ) + data object Logs : NavigationItem( + Screen.LOGS.name, + "Logs", + Icons.Default.List) + data object Settings : NavigationItem( + Screen.SETTINGS.name, + "Settings", + Icons.Default.Settings) +} + +@Composable +fun MPDApp( + navController: NavHostController = rememberNavController(), + settingsViewModel: SettingsViewModel = viewModel() +) { + Scaffold( + topBar = { + + }, + bottomBar = { + BottomNavigationBar(navController) + }, + ) { innerPadding -> + NavHost( + navController = navController, + startDestination = NavigationItem.Home.route, + modifier = Modifier.padding(innerPadding) + ) { + composable(NavigationItem.Home.route) { + StatusScreen(settingsViewModel) + } + composable(NavigationItem.Logs.route) { + LogView(settingsViewModel.getLogs().collectAsStateWithLifecycle()) + } + composable(NavigationItem.Settings.route) { + MPDSettings(settingsViewModel) + } + } + } +} + +@Composable +fun BottomNavigationBar(navController: NavController) { + val navBackStackEntry by navController.currentBackStackEntryAsState() + val currentRoute = navBackStackEntry?.destination?.route + + val items = listOf( + NavigationItem.Home, + NavigationItem.Logs, + NavigationItem.Settings, + ) + + NavigationBar { + items.forEach { item -> + NavigationBarItem( + icon = { + Icon( + imageVector = item.icon, + contentDescription = null + ) + }, + label = { Text (item.label) }, + onClick = { + navController.navigate(item.route) { + popUpTo(navController.graph.startDestinationId) + launchSingleTop = true + } + }, + selected = currentRoute == item.route, + ) + } + } +} \ No newline at end of file diff --git a/android/app/src/main/java/org/musicpd/ui/SettingsActivity.kt b/android/app/src/main/java/org/musicpd/ui/SettingsActivity.kt deleted file mode 100644 index a36fd57e8..000000000 --- a/android/app/src/main/java/org/musicpd/ui/SettingsActivity.kt +++ /dev/null @@ -1,210 +0,0 @@ -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 dagger.hilt.android.AndroidEntryPoint -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.launch -import org.musicpd.Main -import org.musicpd.R - -@AndroidEntryPoint -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() - } - }) - - 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.getLogs().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>) { - 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() - } -} \ No newline at end of file diff --git a/android/app/src/main/java/org/musicpd/ui/Preferences.kt b/android/app/src/main/java/org/musicpd/ui/SettingsScreen.kt similarity index 70% rename from android/app/src/main/java/org/musicpd/ui/Preferences.kt rename to android/app/src/main/java/org/musicpd/ui/SettingsScreen.kt index da357559d..3748260da 100644 --- a/android/app/src/main/java/org/musicpd/ui/Preferences.kt +++ b/android/app/src/main/java/org/musicpd/ui/SettingsScreen.kt @@ -1,5 +1,5 @@ -package org.musicpd.ui - +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.BatteryAlert import androidx.compose.material.icons.filled.Headphones @@ -7,11 +7,35 @@ 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.Modifier +import androidx.compose.ui.platform.LocalContext 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 +import org.musicpd.ui.SettingsViewModel + +@Composable +fun MPDSettings(settingsViewModel: SettingsViewModel) { + val context = LocalContext.current + + Column(Modifier.fillMaxSize()) { + SettingsOptions( + onBootChanged = { newValue -> + if (newValue) { + settingsViewModel.startMPD(context) + } + }, + onWakeLockChanged = { newValue -> + settingsViewModel.setWakelockEnabled(newValue) + }, + onHeadphonesChanged = { newValue -> + settingsViewModel.setPauseOnHeadphonesDisconnect(newValue) + } + ) + } +} @Composable fun SettingsOptions( @@ -49,4 +73,4 @@ fun SettingsOptions( state = headphoneState ) -} +} \ No newline at end of file diff --git a/android/app/src/main/java/org/musicpd/ui/StatusScreen.kt b/android/app/src/main/java/org/musicpd/ui/StatusScreen.kt new file mode 100644 index 000000000..8b24ec143 --- /dev/null +++ b/android/app/src/main/java/org/musicpd/ui/StatusScreen.kt @@ -0,0 +1,119 @@ +package org.musicpd.ui + +import android.Manifest +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +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.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.unit.dp +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 org.musicpd.R + +@OptIn(ExperimentalPermissionsApi::class) +@Composable +fun StatusScreen(settingsViewModel: SettingsViewModel) { + val storagePermissionState = rememberPermissionState( + 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( + Modifier + .padding(4.dp) + .fillMaxSize(), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center + ) { + 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 + ) + } + } + } + } +} + +@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) + } + } +} \ No newline at end of file